---------------------------------------------------------------------- This is the API documentation for the tether library. ---------------------------------------------------------------------- ## Repository The engine and the results it returns. Repo(root: 'Path', config: 'RepoConfig', vcs: 'VcsAdapter', *, allow_outdated: 'bool' = False) -> 'None' A tether dataset: manifests in a VCS working tree plus the systems they name. Construct with `Repo.init` (new dataset) or `Repo.find` (existing one). Every method that touches external systems fans out concurrently across objects and aggregates failures into `MultiObjectError`. Attributes: root: Dataset root (the directory holding `tether.toml`). config: The committed `RepoConfig`. vcs: Adapter for the enclosing jj or git repository. objects: Committed manifests in the working tree, by key. workspace: Untracked per-workspace state (working refs, cached snapshot). StatusReport(manifest_hash: 'str', stale: 'bool', objects: 'list[ObjectStatus]' = , stale_keys: 'list[str]' = , bookmark: 'str | None' = None, trunk: 'bool' = False, bookmark_drift: 'list[str]' = , vcs_drift: 'list[VcsDrift]' = , unprotected: 'list[str]' = , fresh: 'bool' = True, snapshot_at: 'str | None' = None) -> None Result of `Repo.status`. ObjectStatus(key: 'str', kind: 'str', tier: 'Tier', committed: 'bool', pinned: 'bool', recoverable: 'bool', changed: 'bool', current_state: 'State | None', origin: 'str' = 'adopted', verify: 'VerifyReport | None' = None, error: 'str | None' = None, behind: 'bool' = False) -> None One object's classification in a `StatusReport`. VcsDrift(op: 'OpEntry', commit: 'str', referenced: 'dict[str, bool]' = ) -> None A dataset commit that is gone from VCS history without tether knowing. `jj undo`, `jj abandon`, `git reset` -- anything that removes a commit tether made -- leaves the op log believing the commit exists. Detected by `Repo.vcs_drift`, shown by `status` and `ops`. Attributes: op: The `commit` entry. commit: The commit id it recorded. referenced: Key -> whether the working tree's manifest still names the pin that commit made (an undone commit leaves the manifests as uncommitted edits: still referenced; an abandoned one reverts them: not referenced, so `gc` would release the pin). CommitResult(message: 'str', pinned: 'dict[str, Pin | None]' = , unrecoverable: 'list[str]' = , unchanged: 'list[str]' = , vcs_commit: 'str | None' = None) -> None Result of `Repo.commit`. GcReport(unpinned: 'dict[str, list[str]]' = , kept_pins: 'dict[str, list[str]]' = , deleted_working_refs: 'dict[str, list[str]]' = , kept_working_refs: 'dict[str, list[str]]' = , forgotten_working_refs: 'dict[str, list[str]]' = , deleted_listings: 'list[str]' = , deleted_stores: 'dict[str, str]' = , kept_stores: 'dict[str, str]' = , forgotten_stores: 'list[str]' = , dry_run: 'bool' = True, plan: 'Plan | None' = None) -> None Result of `Repo.gc`. AbandonReport(abandoned: 'list[str]' = , gc_plan: 'Plan | None' = None, gc_report: 'GcReport | None' = None) -> None What `Repo.abandon` did. Attributes: abandoned: Commit ids dropped from VCS history. gc_plan: What `gc` would release now that those commits are gone (the pins only they referenced). gc_report: The applied gc, when `gc=True`. DropReport(bookmark: 'str', left_for: 'str | None' = None, abandoned: 'list[str]' = , gc_report: 'GcReport | None' = None, plan: 'Plan | None' = None) -> None What `Repo.drop` did: a bookmark, its commits, and its store leftovers, gone in one step. Attributes: bookmark: The bookmark that was dropped. left_for: The bookmark this checkout moved to first, when it was on the dropped one (`None` otherwise). abandoned: Commit ids that left visible history with it. gc_report: The gc that released its pins, branches, and -- with `delete_stores` -- the stores created on it. plan: The plan that was applied. ForgetWorkspaceReport(workspace: 'str' = '', removed_files: 'list[str]' = , vcs: 'str | None' = None, failed: 'dict[str, str]' = , plan: 'Plan | None' = None) -> None What `Repo.apply_forget_workspace` did. Attributes: workspace: The 8-char workspace id that was forgotten. removed_files: Per-workspace files removed (`workspace.toml`, `ops.jsonl`). vcs: What the VCS did (`jj workspace forget` / `git worktree remove`), if anything. failed: Target -> why a step failed. plan: The plan that was applied. PullReport(bookmark: 'str' = '', committed: 'dict[str, tuple[State, State]]' = , unchanged: 'list[str]' = , skipped: 'dict[str, str]' = , vcs_commit: 'str | None' = None, pinned: 'dict[str, Pin | None]' = ) -> None What `pull` did: the fetched heads of a bookmark's branches, committed. SetReport(changed: 'dict[str, dict[str, tuple[str, str]]]' = , unchanged: 'list[str]' = ) -> None What `set` changed per object. PromoteReport(fast_forwarded: 'dict[str, State]' = , merged: 'dict[str, State]' = , skipped: 'list[str]' = , refused: 'dict[str, str]' = , held: 'dict[str, str]' = , conflicts: 'dict[str, list[str]]' = , kept_forks: 'dict[str, str]' = , trunk_moved: 'str | None' = None, trunk_held: 'str | None' = None, plan: 'Plan | None' = None) -> None Result of `Repo.apply_promote`. UndoReport(op: 'OpEntry', undo_id: 'str' = '', restored: 'list[str]' = , irreversible: 'list[str]' = , skipped: 'list[str]' = ) -> None What `Repo.undo` reversed, could not reverse, and left alone. Attributes: op: The operation that was undone. undo_id: Id of the `undo` entry appended to the op log. restored: What was put back (one line each). irreversible: What the stores no longer allow to be put back. skipped: Parts that no longer applied (e.g. the working copy had moved). RepairReport(repinned: 'dict[str, str]' = , reforked: 'dict[str, str]' = , failed: 'dict[str, str]' = , plan: 'Plan | None' = None) -> None What `Repo.repair` rebuilt. Attributes: repinned: Object key -> pin id recreated from the manifest's state. reforked: Object key -> working branch recreated from the manifest. failed: Target -> why it could not be rebuilt (state no longer reachable). plan: The plan that was applied. RecoverReport(dataset_id: 'str', trunk: 'str', objects: 'list[RecoveredObject]' = , pinned: 'bool' = False, suggested_id: 'str | None' = None, scoped: 'bool' = False, steps: 'list[RecoverStep]' = ) -> None What `Repo.recover_report` found: tether's refs in each object's store, by the dataset id they carry, and what to run to take them back. RecoverStep(command: 'str | None', note: 'str') -> None One step of a `RecoverReport`: a command to run, or none, and a note. RecoveredObject(key: 'str', kind: 'str', namespaces: 'dict[str, RecoveredRefs]' = , holds_refs: 'bool' = True, error: 'str | None' = None, missing_pin: 'str | None' = None, unrecognized: 'list[str]' = ) -> None One object's row in a `RecoverReport`. RecoveredRefs(dataset_id: 'str', pins: 'set[str]' = , bookmarks: 'dict[str, list[str]]' = , legacy: 'dict[str, list[str]]' = ) -> None The refs a store holds under one dataset id (see `RecoverReport`). DiffEntry(key: 'str', change: 'str', a_pin: 'str | None' = None, b_pin: 'str | None' = None, detail: 'ObjectDiff | None' = None, detail_error: 'str | None' = None, why: 'tuple[str, ...]' = ()) -> None One object's row in `Repo.diff`. TETHER_REV_ENV str(object='') -> str str(bytes_or_buffer[, encoding[, errors]]) -> str Create a new string object from the given object. If encoding or errors is specified, then the object must expose a data buffer that will be decoded using the given encoding and error handler. Otherwise, returns the result of object.__str__() (if defined) or repr(object). encoding defaults to sys.getdefaultencoding(). errors defaults to 'strict'. ## Operation log What tether did to the stores, per workspace; what undo works from. OpEntry(id: 'str', at: 'str', command: 'str', plan: 'dict[str, Any] | None' = None, result: 'dict[str, Any]' = , pre: 'dict[str, Any]' = , undoes: 'str | None' = None, undone_by: 'str | None' = None, status: 'str' = 'done', parent: 'str | None' = None, progress: 'list[dict[str, Any]]' = ) -> None One applied operation. Attributes: id: 12-hex identifier (`tether undo ID`). at: When it was applied (UTC, ISO 8601). command: `commit`, `new`, `fork`, `gc`, `promote`, `import`, `add`, `remove`, `repair`, or `undo`. plan: The `Plan` that was applied, as a dict (`None` for direct ops). result: What happened: pins created, branches forked, the VCS commit, the report -- whatever the command produced. pre: What `undo` needs to reverse it: workspace state before, VCS position before, branch heads before they were reset, manifests before they were rewritten. undoes: For an `undo` entry, the id of the operation it reversed. undone_by: Set on an entry once an `undo` has reversed it. status: `"started"` while the operation runs -- the entry is appended (and synced) *before* the first side effect, with the plan and what `undo` would need -- and `"done"` once a completion mark with the result follows. An entry still `"started"` in a log nobody is writing to is an operation that was interrupted: `ops` flags it, `undo` refuses it (what happened is not known), `repair` names it. parent: The id of the operation this one is a step of (`drop` leaves the bookmark with a `new` and sweeps with a `gc`, each journaled). A step is undone with its parent or not at all: `undo` refuses it on its own, so half of a drop cannot come back. OpEntry(id: 'str', at: 'str', command: 'str', plan: 'dict[str, Any] | None' = None, result: 'dict[str, Any]' = , pre: 'dict[str, Any]' = , undoes: 'str | None' = None, undone_by: 'str | None' = None, status: 'str' = 'done', parent: 'str | None' = None, progress: 'list[dict[str, Any]]' = ) -> None One applied operation. Attributes: id: 12-hex identifier (`tether undo ID`). at: When it was applied (UTC, ISO 8601). command: `commit`, `new`, `fork`, `gc`, `promote`, `import`, `add`, `remove`, `repair`, or `undo`. plan: The `Plan` that was applied, as a dict (`None` for direct ops). result: What happened: pins created, branches forked, the VCS commit, the report -- whatever the command produced. pre: What `undo` needs to reverse it: workspace state before, VCS position before, branch heads before they were reset, manifests before they were rewritten. undoes: For an `undo` entry, the id of the operation it reversed. undone_by: Set on an entry once an `undo` has reversed it. status: `"started"` while the operation runs -- the entry is appended (and synced) *before* the first side effect, with the plan and what `undo` would need -- and `"done"` once a completion mark with the result follows. An entry still `"started"` in a log nobody is writing to is an operation that was interrupted: `ops` flags it, `undo` refuses it (what happened is not known), `repair` names it. parent: The id of the operation this one is a step of (`drop` leaves the bookmark with a `new` and sweeps with a `gc`, each journaled). A step is undone with its parent or not at all: `undo` refuses it on its own, so half of a drop cannot come back. TouchedStore(dataset_id: 'str', kind: 'str', identity: 'dict[str, Any]', locator: 'dict[str, Any]', key: 'str', at: 'str') -> None One store this clone forked or pinned in (`gc` releases its own dead refs there even when no manifest names the store any more). tether_path(root: 'Path') -> 'Path' new_op_id() -> 'str' A fresh 12-hex operation id. ops_path(root: 'Path') -> 'Path' report_dict(report: 'Any') -> 'dict[str, Any]' A report dataclass as plain data for the op log (without its plan). Every `apply_*` -- in the core, in `tether.upgrade`, in `tether.experimental.registry` -- records its report this way, so the helper lives with the log rather than as a private name in `repo.py`. read_ops(root: 'Path') -> 'list[OpEntry]' Every entry, oldest first, with undo marks applied (missing log: empty). read_jsonl(path: 'Path') -> 'list[dict[str, Any]]' Every decodable record of a jsonl index, oldest first. A line an interrupted append left torn is skipped, as `read_ops` skips one: an index is a memory aid, and a damaged line must never fail the command that happens to read it. append_index_entry(path: 'Path', data: 'dict[str, Any]') -> 'None' Append one record and sync it (one `write` of one line, O_APPEND). A torn final line -- an earlier append that was interrupted -- has no newline; the new record starts on a fresh line so the fragment stays a fragment (skipped by `read_jsonl`) instead of swallowing this record too. remove_index_entry(path: 'Path', dataset_id: 'str', identity: 'dict[str, Any]') -> 'None' touched_path(shared_dir: 'Path') -> 'Path' append_touched(shared_dir: 'Path', entry: 'TouchedStore') -> 'bool' Record a store this clone just wrote a ref into; a set, so an identity already on record is not written again. Returns whether it was new. read_touched(shared_dir: 'Path', dataset_id: 'str | None' = None) -> 'list[TouchedStore]' Every touched store on record (optionally one dataset's), oldest first. remove_touched(shared_dir: 'Path', dataset_id: 'str', identity: 'dict[str, Any]') -> 'None' Forget a store (nothing of this dataset's is left in it). pinned_path(shared_dir: 'Path') -> 'Path' read_pinned(shared_dir: 'Path', dataset_id: 'str') -> 'set[str]' The pin ids on record for `dataset_id`. append_pinned(shared_dir: 'Path', dataset_id: 'str', pins: 'Iterable[tuple[str, str, str]]') -> 'None' Record `(pin id, kind, key)` triples this clone just created; a set, so an id already on record is not written again. Creates the index when it is missing, even for no pins: its presence says it has been seeded. remove_pinned(shared_dir: 'Path', dataset_id: 'str', pin_ids: 'Iterable[str]') -> 'None' Forget pins this clone released. mark_progress(root: 'Path', op_id: 'str', action: 'str', **detail: 'Any') -> 'None' Record that one action of a running operation completed. A multi-action operation (a `new` forking five branches, a `gc` releasing twenty pins) that dies half-way leaves a started entry; these records say which of its actions had already taken effect, so `repair` can list them and a re-run knows what is left. append_op(root: 'Path', entry: 'OpEntry') -> 'None' mark_done(root: 'Path', op_id: 'str', result: 'dict[str, Any]', pre: 'dict[str, Any] | None' = None, *, undone: 'str | None' = None) -> 'None' Complete a started entry: what happened, and anything `undo` learnt only while the operation ran (branch heads it replaced, for one). An undo names the entry it reversed in the same record, so completing the undo and marking its target undone is one append -- there is no state where the undo is done but its target still counts as undoable. mark_undone(root: 'Path', op_id: 'str', by: 'str') -> 'None' Record that `op_id` was reversed by the undo entry `by` (an appended mark). oplog.OPS_FILENAME str(object='') -> str str(bytes_or_buffer[, encoding[, errors]]) -> str Create a new string object from the given object. If encoding or errors is specified, then the object must expose a data buffer that will be decoded using the given encoding and error handler. Otherwise, returns the result of object.__str__() (if defined) or repr(object). encoding defaults to sys.getdefaultencoding(). errors defaults to 'strict'. oplog.TOUCHED_FILENAME str(object='') -> str str(bytes_or_buffer[, encoding[, errors]]) -> str Create a new string object from the given object. If encoding or errors is specified, then the object must expose a data buffer that will be decoded using the given encoding and error handler. Otherwise, returns the result of object.__str__() (if defined) or repr(object). encoding defaults to sys.getdefaultencoding(). errors defaults to 'strict'. oplog.PINNED_FILENAME str(object='') -> str str(bytes_or_buffer[, encoding[, errors]]) -> str Create a new string object from the given object. If encoding or errors is specified, then the object must expose a data buffer that will be decoded using the given encoding and error handler. Otherwise, returns the result of object.__str__() (if defined) or repr(object). encoding defaults to sys.getdefaultencoding(). errors defaults to 'strict'. ## OpEntry Methods Methods for the OpEntry class now(command: 'str', *, plan: 'dict[str, Any] | None' = None, result: 'dict[str, Any] | None' = None, pre: 'dict[str, Any] | None' = None, undoes: 'str | None' = None, parent: 'str | None' = None) -> 'OpEntry' OpEntry.incomplete The operation began but no completion mark followed. to_dict(self) -> 'dict[str, Any]' from_dict(data: 'dict[str, Any]') -> 'OpEntry' OpEntry.undoable Not itself an undo, not already undone, complete, not a step of another operation, and of a reversible kind. summary(self) -> 'str' One line for `tether ops`. ## Migrations The one composed migration behind `tether upgrade`, from any 0.1.0 alpha format to the current one. Ships with the betas and is removed at 0.1.0. ## Plans What a store-writing command would do, as data. `Repo.plan_*` returns a `Plan`; `Repo.apply_*` executes one after re-checking the world matches. Plan(command: 'str', actions: 'list[Action]' = , context: 'dict[str, Any]' = , notes: 'list[str]' = , preconditions: 'list[Precondition]' = , created_at: 'str' = , tether_version: 'str' = , saved_digest: 'str | None' = None) -> None The actions a command would perform, plus the inputs they depend on. Precondition(kind: 'str', expected: 'Any' = None, key: 'str | None' = None, params: 'dict[str, Any]' = , detail: 'str' = '') -> None One thing that must still hold when a plan is applied. The drift contract in data: a plan records what it saw (a manifest hash, a branch head, a history digest) and apply refuses with :class:`~tether.errors.StalePlanError` if the world says otherwise. Kept out of `context` so the checks are typed, listed, and run in one place rather than re-implemented per command. Attributes: kind: One of :data:`PRECONDITION_KINDS`. expected: The value seen at plan time (a hash, a state, a commit id). key: The object key, for per-object checks. params: What the check needs to look again (`locator`, `backend`, `ref`, `rev`, `pin`, `bookmark`). detail: The refusal message; may use `{observed}`. Action(op: 'str', key: 'str' = '', kind: 'str' = '', target: 'str' = '', detail: 'str' = '', params: 'dict[str, Any]' = ) -> None One store-writing step in a `Plan`. ## Plan Methods Methods for the Plan class Plan.writes Actions that touch an external system or the working tree. Plan.is_empty require(self, kind: 'str', expected: 'Any' = None, *, key: 'str | None' = None, detail: 'str' = '', **params: 'Any') -> 'None' Append a precondition (see :class:`Precondition`). digest(self) -> 'str' SHA-256 of everything else in the saved plan, as JSON reads it back (keys sorted, values through `str` where JSON has no type). The preconditions vouch for the world the actions and context were computed from; the digest binds the three together, so a saved plan edited in one place -- an action dropped, a `target_state` or `bookmark_commit` changed -- is refused at apply rather than applied under checks that describe another plan. It detects edits, it does not authenticate: whoever can write the file can recompute it. to_dict(self) -> 'dict[str, Any]' to_json(self) -> 'str' from_dict(data: 'dict[str, Any]') -> 'Plan' Load a saved plan. Raises: StalePlanError: Another tether version made the plan, or it records none (saved before plans recorded it). ConfigError: The plan's format is not :data:`PLAN_FORMAT`. edited(self) -> 'bool' Whether this plan was loaded and its content no longer matches the digest it was saved with (see :meth:`digest`). from_json(text: 'str') -> 'Plan' missing_preconditions(self) -> 'list[str]' What this plan lacks of :data:`REQUIRED_PRECONDITIONS` and :data:`REQUIRED_ACTION_PRECONDITIONS`, as `kind` or `kind for key` entries; empty when it carries everything its command requires. render(self) -> 'list[str]' Human-readable lines, one per action, then notes. ## Manifest model What is committed to the VCS (`tether.toml`, `.tether/objects/*.toml`) and kept untracked (`.tether/workspace.toml`), plus the hashing and naming helpers behind pin ids and refs. RepoConfig(version: 'int' = 5, dataset_id: 'str' = , snapshot_auto: 'bool' = False, verify_on_status: 'bool' = False, new_fork: 'str' = 'lazy', new_record: 'str' = 'fork', defaults: 'Policy' = , vcs: 'dict[str, Any]' = , backends: 'dict[str, dict[str, Any]]' = , committed_import_query: 'str | None' = None) -> None Committed repository configuration. Policy(file: 'FileMode' = 'immutable', pin: 'PinMode' = 'native') -> None Per-object behavioral knobs. Whether writes fork a branch or land on the upstream branch is not a policy any more: it is which bookmark the working copy is on (the trunk writes upstream, anything else forks). Manifests from before that carry a ``write`` key; it is read and ignored. Pin(id: 'str', ref: 'str', created: 'bool' = True) -> None A durable native reference created for a committed state. ObjectManifest(key: 'str', kind: 'str', locator: 'Locator', policy: 'Policy' = , state: 'State | None' = None, pin: 'Pin | None' = None, captured_at: 'str | None' = None, recoverable: 'bool' = True, origin: "Literal['adopted', 'created']" = 'adopted') -> None The committed record for one object. ``state``/``pin`` are ``None`` until the object has been committed at least once. ``recoverable`` is ``False`` for Observed-tier objects whose committed state cannot be reconstructed later (e.g. a mutable file by mtime). WorkspaceState(workspace_id: 'str' = , bookmark: 'str | None' = None, base_states: 'dict[str, State]' = , working_refs: 'dict[str, str]' = , pending_forks: 'dict[str, str]' = , pending_resets: 'dict[str, State]' = , fork_points: 'dict[str, State]' = , last_snapshot: 'dict[str, State]' = , last_snapshot_at: 'str | None' = None, last_snapshot_errors: 'dict[str, dict[str, str]]' = ) -> None Untracked per-workspace working state. ``base_states`` maps object key -> the committed state this workspace's working ref corresponds to (set when the branch is forked and whenever this workspace commits the object); a manifest that says something else was changed by someone else, and the workspace is *stale* for that object. ``working_refs`` maps object key -> native working ref that exists. ``pending_forks`` maps object key -> the branch name ``new`` decided on but has not created yet (lazy forking: it is created on the first writable ``open``); ``pending_resets`` records, for a pending fork whose branch already existed when ``new`` planned it, the head ``new`` saw and agreed to reset -- the open resets only if it is still there. ``fork_points`` maps object key -> the state its working branch was created from; ``promote`` compares the base branch against it to tell a fast-forward from a divergence. ``bookmark`` is the dataset bookmark this checkout works on: its store branches are this workspace's working refs (``None``: read-only, no working refs). ``last_snapshot`` caches the most recent fan-out fingerprints; ``last_snapshot_errors`` the objects that fan-out could not read, with the branch it tried (``ref``, empty for upstream) and the ``error``. compute_pin_id(kind: 'str', identity: 'Locator', state: 'State', dataset_id: 'str') -> 'str' Content-address a pin: ``.``. The hash covers ``(kind, locator identity, state)`` and is stable across processes and machines, so re-committing an unchanged object is a no-op and identical states dedupe to one pin *within a dataset*. The dataset id in front is the namespace: several datasets can pin the same store and each one's ``gc`` only ever sees its own pins. Callers pass the *content* state (see :func:`tether.backends.base.content_state`). ref_for_pin(pin_id: 'str') -> 'str' Native reference name for a pin id (dot-delimited; no ``/``). working_ref_name(dataset_id: 'str', bookmark: 'str') -> 'str' The store branch that stands for a dataset bookmark. ``tether.ws..``: one branch per bookmark in each Forkable system (objects sharing a system share it). The dataset id is the namespace ``gc`` stays inside; ``tether.ws.`` is what backends list as working refs. working_ref_bookmark(ref: 'str') -> 'str | None' The bookmark slug a working ref stands for; ``None`` for legacy per-workspace names (see `working_ref_workspace`) and foreign refs. working_ref_workspace(ref: 'str') -> 'str | None' The 8-char workspace id in a *legacy* working ref name (before bookmarks); ``None`` for bookmark-named refs. listing_name(kind: 'str', identity: 'Locator', state: 'State') -> 'str' Content-addressed file name for a state's listing. Derived from the same inputs as the pin id (see ``ObjectBackend.listing``), so the engine can locate a listing from a manifest alone, at commit time and at diff time. manifest_hash(objects: 'dict[str, ObjectManifest]') -> 'str' Hash the full committed object set (order-independent). Serves as the dataset "tree id" and the anchor for stale detection. ## Handles Typed native handles returned by `Repo.open`. Handle(key: 'str', read_only: 'bool') -> None Base class for all native handles. FileHandle(key: 'str', read_only: 'bool', uri: 'str', version_id: 'str | None' = None) -> None A plain file or object-store artifact (always read-only). IcechunkHandle(key: 'str', read_only: 'bool', repository: 'Any', session: 'Any' = None, branch: 'str | None' = None, tag: 'str | None' = None, snapshot_id: 'str | None' = None) -> None An Icechunk repository positioned at a branch (write) or tag (read). ``session`` is a ready-to-use Icechunk session (writable for a branch, read-only for a tag/snapshot); ``repository`` is provided for callers that want to open their own sessions. NeonHandle(key: 'str', read_only: 'bool', url: 'str', branch: 'str' = '') -> None A Neon Postgres connection URL for a branch or a pinned point in time. `url` carries the role's password (it is what `tether open` hands to `psql`); it is kept out of `repr` and :attr:`redacted_url` is what anything that logs or displays the handle should use. GitHandle(key: 'str', read_only: 'bool', path: 'str', sha: 'str', worktree: 'str | None' = None) -> None A git/jj repository positioned at a sha (and optional worktree path). IcebergHandle(key: 'str', read_only: 'bool', table: 'Any', ref: 'str | None' = None, snapshot_id: 'int | None' = None) -> None A pyiceberg table positioned at a branch (write) or tag (read). DeltaHandle(key: 'str', read_only: 'bool', uri: 'str', version: 'int', table: 'Any') -> None A Delta Lake table loaded at a specific version (always read-only). ``table`` is a ``deltalake.DeltaTable``; write through ``deltalake`` directly (tether only records and re-addresses versions). LanceHandle(key: 'str', read_only: 'bool', uri: 'str', dataset: 'Any', version: 'int', branch: 'str | None' = None, tag: 'str | None' = None) -> None A Lance dataset checked out at a branch (write) or tag/version (read). ``dataset`` is a ``lance.LanceDataset``; append with ``lance.write_dataset(table, handle.dataset, mode="append")``. DuckLakeHandle(key: 'str', read_only: 'bool', metadata: 'str', alias: 'str', connection: 'Any', snapshot_id: 'int', attach_sql: 'str', data_path: 'str | None' = None, table: 'str | None' = None) -> None A DuckLake catalog attached (read-only) in a DuckDB connection. The catalog is attached as ``alias`` in ``connection``, positioned at ``snapshot_id`` when opened at a committed state. ``attach_sql`` is the exact statement used, for callers that want the same view in their own DuckDB process. Call :meth:`close` when done; a DuckDB-file metadata catalog can only be attached once per process. DoltHandle(key: 'str', read_only: 'bool', url: 'str', database: 'str', ref: 'str', commit: 'str | None' = None) -> None A Dolt revision database: ``mysql://user@host:port/db/``. ``ref`` is a branch (writable) or a tag / commit hash (read-only). Connect with ``database=handle.database_ref`` or ``USE db/ref``; the password is never carried in the handle. MemoryHandle(key: 'str', read_only: 'bool', store: 'Any', system: 'str', ref: 'str') -> None In-memory reference handle used by the conformance suite and tests. redact_url(url: 'str') -> 'str' `url` with any password replaced by `***`. ## Backend protocol Implement `ObjectBackend` and register it to add a system; capability flags decide which commands apply. ChangeEntry(path: 'str', change: 'str', detail: 'str' = '') -> None One changed thing inside an object: a file, table, array, key, ... HistoryEntry(id: 'str', when: 'str | None' = None, message: 'str' = '', refs: 'list[str]' = ) -> None One native commit / snapshot / version, as listed by `ObjectBackend.history`. `id` is what the backend accepts as the locator's `at` field, so a user can pick an entry and register (or re-base) an object at exactly that state. ObjectDiff(unit: 'str' = 'entries', added: 'int' = 0, removed: 'int' = 0, modified: 'int' = 0, entries: 'list[ChangeEntry]' = , truncated: 'bool' = False, note: 'str' = '') -> None What changed inside one object between two recorded states. Backends describe changes at whatever granularity their system exposes natively and cheaply (files, tables, arrays, fragments, versions); ``unit`` names it. Entries are capped at :data:`MAX_DIFF_ENTRIES`; counts are not. VerifyReport(status: 'VerifyStatus', message: 'str' = '') -> None Result of `ObjectBackend.verify` for one object. ObjectBackend(*args, **kwargs) The operations tether needs from one class of system. ``listing``, ``diff``, and ``history`` have default implementations (no listing; capability errors) so backends without ``DIFF`` / ``HISTORY`` need not define them. Locators may carry an ``at`` field naming a specific native state (a snapshot id, version, commit, or tag). A backend that supports it treats the object as *detached* there: ``fingerprint(locator, None)`` returns that state instead of the base branch's head, so ``commit`` pins it and ``new`` forks from it. Once a working ref exists it takes precedence. Capability(value, names=None, *, module=None, qualname=None, type=None, start=1, boundary=None) What a backend can do. The first four flags are cumulative tiers (see `Tier` and `tier_of`); the rest are orthogonal refinements the engine and the docs use to explain behavior. Tier(value, names=None, *, module=None, qualname=None, type=None, start=1, boundary=None) The cumulative capability tier of a backend (or of one object). VerifyStatus(value, names=None, *, module=None, qualname=None, type=None, start=1, boundary=None) Outcome of `ObjectBackend.verify`. base_at(locator: 'Locator') -> 'str | None' The locator's ``at`` field (a detached base state), if any. build_backend(kind: 'str', config: 'dict | None' = None) -> 'ObjectBackend' content_state(backend: 'ObjectBackend', state: 'State | None') -> 'State | None' The part of a state that identifies the data: the state minus ``backend.VOLATILE_KEYS``. Everything that asks "is this the same thing?" -- drift detection, the unchanged check in ``commit``, pin ids, listing names, export hashes, ``promote``'s fork-point comparison -- goes through this. Everything that asks "where is it?" (``open``, ``pin``, ``verify``) uses the full state. effective_capabilities(backend: 'ObjectBackend', locator: 'Locator', policy: 'object') -> 'Capability' Capabilities for a specific object. Some backends (notably ``file``) span tiers depending on the locator and policy: a local file is Observed while an S3 object in a versioned bucket is Addressable. Backends may implement ``effective_capabilities(locator, policy)`` to refine their class-level :attr:`capabilities`; otherwise the class value is used. ``policy.pin == "record"`` removes ``PIN`` for any backend: the state is recorded without a native ref (cheaper, no retention hold) and forks come straight from the recorded state while the system still has it. known_kinds() -> 'list[str]' register_backend(kind: 'str', factory: 'BackendFactory') -> 'None' tier_of(caps: 'Capability') -> 'Tier' backends.MAX_DIFF_ENTRIES int([x]) -> integer int(x, base=10) -> integer Convert a number or string to an integer, or return 0 if no arguments are given. If x is a number, return x.__int__(). For floating point numbers, this truncates towards zero. If x is not a number or if base is given, then x must be a string, bytes, or bytearray instance representing an integer literal in the given base. The literal can be preceded by '+' or '-' and be surrounded by whitespace. The base defaults to 10. Valid bases are 0 and 2-36. Base 0 means to interpret the base from the string as an integer literal. >>> int('0b100', base=0) 4 tuple(*args, **kwargs) Built-in immutable sequence. If no argument is given, the constructor returns an empty tuple. If iterable is specified the tuple is initialized from iterable's items. If the argument is a tuple, the return value is the same object. ## Conformance suite Capability-parametrized checks for backend authors. BackendError(message: 'str', *, key: 'str | None' = None, kind: 'str | None' = None) -> 'None' A backend operation failed. Carries the object ``key`` and backend ``kind`` when known so aggregated errors stay legible. RefMovedError(message: 'str', *, key: 'str | None' = None, kind: 'str | None' = None) -> 'None' A conditional ref move found the ref elsewhere than `expected`. Raised by ``fork``, ``promote`` and ``merge`` when the caller passed the head it reviewed and the ref no longer holds it (or exists when it was expected absent); nothing was written. The engine reports it as a plan gone stale: re-plan, and review what moved. ObjectDiff(unit: 'str' = 'entries', added: 'int' = 0, removed: 'int' = 0, modified: 'int' = 0, entries: 'list[ChangeEntry]' = , truncated: 'bool' = False, note: 'str' = '') -> None What changed inside one object between two recorded states. Backends describe changes at whatever granularity their system exposes natively and cheaply (files, tables, arrays, fragments, versions); ``unit`` names it. Entries are capped at :data:`MAX_DIFF_ENTRIES`; counts are not. Handle(key: 'str', read_only: 'bool') -> None Base class for all native handles. Pin(id: 'str', ref: 'str', created: 'bool' = True) -> None A durable native reference created for a committed state. Policy(file: 'FileMode' = 'immutable', pin: 'PinMode' = 'native') -> None Per-object behavioral knobs. Whether writes fork a branch or land on the upstream branch is not a policy any more: it is which bookmark the working copy is on (the trunk writes upstream, anything else forks). Manifests from before that carry a ``write`` key; it is read and ignored. ObjectBackend(*args, **kwargs) The operations tether needs from one class of system. ``listing``, ``diff``, and ``history`` have default implementations (no listing; capability errors) so backends without ``DIFF`` / ``HISTORY`` need not define them. Locators may carry an ``at`` field naming a specific native state (a snapshot id, version, commit, or tag). A backend that supports it treats the object as *detached* there: ``fingerprint(locator, None)`` returns that state instead of the base branch's head, so ``commit`` pins it and ``new`` forks from it. Once a working ref exists it takes precedence. BackendHarness(*args, **kwargs) Test fixture a backend author provides to drive the suite. Capability(value, names=None, *, module=None, qualname=None, type=None, start=1, boundary=None) What a backend can do. The first four flags are cumulative tiers (see `Tier` and `tier_of`); the rest are orthogonal refinements the engine and the docs use to explain behavior. VerifyStatus(value, names=None, *, module=None, qualname=None, type=None, start=1, boundary=None) Outcome of `ObjectBackend.verify`. accepts_expected(method: 'Callable[..., Any]') -> 'bool' Whether a backend's `fork`, `promote` or `merge` takes the `expected` keyword (see :meth:`ObjectBackend.fork`). Backends written before 0.1.0b4 do not declare it; the engine calls those without it and their moves are unconditional, as they always were, rather than failing every fork with a `TypeError`. content_state(backend: 'ObjectBackend', state: 'State | None') -> 'State | None' The part of a state that identifies the data: the state minus ``backend.VOLATILE_KEYS``. Everything that asks "is this the same thing?" -- drift detection, the unchanged check in ``commit``, pin ids, listing names, export hashes, ``promote``'s fork-point comparison -- goes through this. Everything that asks "where is it?" (``open``, ``pin``, ``verify``) uses the full state. effective_capabilities(backend: 'ObjectBackend', locator: 'Locator', policy: 'object') -> 'Capability' Capabilities for a specific object. Some backends (notably ``file``) span tiers depending on the locator and policy: a local file is Observed while an S3 object in a versioned bucket is Addressable. Backends may implement ``effective_capabilities(locator, policy)`` to refine their class-level :attr:`capabilities`; otherwise the class value is used. ``policy.pin == "record"`` removes ``PIN`` for any backend: the state is recorded without a native ref (cheaper, no retention hold) and forks come straight from the recorded state while the system still has it. compute_pin_id(kind: 'str', identity: 'Locator', state: 'State', dataset_id: 'str') -> 'str' Content-address a pin: ``.``. The hash covers ``(kind, locator identity, state)`` and is stable across processes and machines, so re-committing an unchanged object is a no-op and identical states dedupe to one pin *within a dataset*. The dataset id in front is the namespace: several datasets can pin the same store and each one's ``gc`` only ever sees its own pins. Callers pass the *content* state (see :func:`tether.backends.base.content_state`). ref_for_pin(pin_id: 'str') -> 'str' Native reference name for a pin id (dot-delimited; no ``/``). working_ref_name(dataset_id: 'str', bookmark: 'str') -> 'str' The store branch that stands for a dataset bookmark. ``tether.ws..``: one branch per bookmark in each Forkable system (objects sharing a system share it). The dataset id is the namespace ``gc`` stays inside; ``tether.ws.`` is what backends list as working refs. run_conformance(harness: 'BackendHarness') -> 'None' Run all capability-appropriate checks; raise ``AssertionError`` on fail. The suite uses ``harness.capabilities`` when provided (so a backend whose effective tier varies per object -- e.g. ``file`` -- can be exercised at a specific tier), otherwise the backend's class-level capabilities. testing.ABSENT dict() -> new empty dictionary dict(mapping) -> new dictionary initialized from a mapping object's (key, value) pairs dict(iterable) -> new dictionary initialized as if via: d = {} for k, v in iterable: d[k] = v dict(**kwargs) -> new dictionary initialized with the name=value pairs in the keyword argument list. For example: dict(one=1, two=2) dict(*args, **kwargs) dict() -> new empty dictionary dict(mapping) -> new dictionary initialized from a mapping object's (key, value) pairs dict(iterable) -> new dictionary initialized as if via: d = {} for k, v in iterable: d[k] = v dict(**kwargs) -> new dictionary initialized with the name=value pairs in the keyword argument list. For example: dict(one=1, two=2) testing.CONFORMANCE_DATASET str(object='') -> str str(bytes_or_buffer[, encoding[, errors]]) -> str Create a new string object from the given object. If encoding or errors is specified, then the object must expose a data buffer that will be decoded using the given encoding and error handler. Otherwise, returns the result of object.__str__() (if defined) or repr(object). encoding defaults to sys.getdefaultencoding(). errors defaults to 'strict'. testing.RACERS int([x]) -> integer int(x, base=10) -> integer Convert a number or string to an integer, or return 0 if no arguments are given. If x is a number, return x.__int__(). For floating point numbers, this truncates towards zero. If x is not a number or if base is given, then x must be a string, bytes, or bytearray instance representing an integer literal in the given base. The literal can be preceded by '+' or '-' and be surrounded by whitespace. The base defaults to 10. Valid bases are 0 and 2-36. Base 0 means to interpret the base from the string as an integer literal. >>> int('0b100', base=0) 4 ## Registries (experimental) Export tables (`ExportBundle`, `TABLES`), Postgres publishing, and the canonical import columns / sources for `import`. Lives under `tether.experimental.registry`; import the public names from `tether`. ## Store lifecycle (experimental) `add --create` / `Repo.create` make a store tether owns; `gc --delete-stores` removes it once nothing references it. The created- and touched-store indexes, the planning rules, and the graduation criteria live in `tether.experimental.lifecycle`. ## VCS adapters How tether reads and writes history through jj or git. VcsError A version-control operation failed. GitObjectReader(git_exe: 'str', cwd: 'Path', git_dir: 'Path | None' = None) -> 'None' Read many objects through one ``git cat-file --batch`` process. Trees and blobs are cached by object id, so walking a long history whose manifests rarely change costs one round-trip per commit plus one per *distinct* manifest -- not one process per commit per file. JjAdapter(root: 'Path', executable: 'str' = 'jj', git_executable: 'str | None' = None) -> 'None' GitAdapter(root: 'Path', executable: 'str' = 'git') -> 'None' CommitInfo(commit_id: 'str', parents: 'tuple[str, ...]', author_name: 'str', author_email: 'str', authored_at: 'str', committed_at: 'str', message: 'str', change_id: 'str | None' = None) -> None Metadata of one VCS commit, as exported to registries. RefInfo(name: 'str', kind: 'str', commit_id: 'str') -> None A named pointer into history: a jj bookmark, git branch, tag, or the head. VcsAdapter(*args, **kwargs) The VCS surface tether relies on. Paths are relative to :attr:`root`. git_env(extra: 'dict[str, str] | None' = None) -> 'dict[str, str]' The environment for a git (or jj) subprocess: this process's, without the variables that point git at another repository or add config to it, plus `extra`. require_git_version(exe: 'str') -> 'None' Refuse a git older than `MIN_GIT_VERSION`; an unparseable banner (a custom build) passes, as jj's does. Raises: VcsError: `exe` is too old, missing, or fails `--version`. detect_vcs(start: 'Path', *, jj_path: 'str | None' = None, git_path: 'str | None' = None, prefer: 'str' = 'jj') -> 'VcsAdapter' Discover the enclosing VCS, preferring jj when repos are colocated. vcs.MIN_JJ_VERSION Built-in immutable sequence. If no argument is given, the constructor returns an empty tuple. If iterable is specified the tuple is initialized from iterable's items. If the argument is a tuple, the return value is the same object. vcs.MIN_GIT_VERSION Built-in immutable sequence. If no argument is given, the constructor returns an empty tuple. If iterable is specified the tuple is initialized from iterable's items. If the argument is a tuple, the return value is the same object. ## Errors TetherError Base class for all tether errors. ConfigError The repository configuration or manifest layout is invalid. VcsError A version-control operation failed. BackendError(message: 'str', *, key: 'str | None' = None, kind: 'str | None' = None) -> 'None' A backend operation failed. Carries the object ``key`` and backend ``kind`` when known so aggregated errors stay legible. CapabilityError(message: 'str', *, key: 'str | None' = None, kind: 'str | None' = None) -> 'None' An operation was requested that the backend does not support. Raised, for example, when ``open`` is asked for a writable handle on an object whose backend is not ``FORK``-capable. ImmutableObjectModified(message: 'str', *, key: 'str | None' = None, kind: 'str | None' = None) -> 'None' An object registered as immutable changed on disk / in storage. UnpinnedStateError(message: 'str', *, key: 'str | None' = None, kind: 'str | None' = None) -> 'None' A pinnable object has working state that was never pinned. PinDriftError(message: 'str', *, key: 'str | None' = None, kind: 'str | None' = None) -> 'None' A pin no longer resolves to the state recorded in the manifest. StaleWorkingCopyError The workspace was forked from a manifest that no longer matches HEAD. The caller should run :meth:`tether.repo.Repo.new` to refork working branches before mutating any object. StalePlanError A saved plan no longer matches the world it was computed from. Raised by ``apply_*`` when object states, manifests, or the revision differ from what the plan recorded; re-plan instead of applying stale actions. MergeConflict(message: 'str', *, conflicts: 'list[str] | None' = None, key: 'str | None' = None, kind: 'str | None' = None) -> 'None' A native merge stopped on conflicts; nothing was written. ``conflicts`` names the conflicting units (paths, tables, arrays) the system reported, so the caller can resolve them with the system's tools. RefMovedError(message: 'str', *, key: 'str | None' = None, kind: 'str | None' = None) -> 'None' A conditional ref move found the ref elsewhere than `expected`. Raised by ``fork``, ``promote`` and ``merge`` when the caller passed the head it reviewed and the ref no longer holds it (or exists when it was expected absent); nothing was written. The engine reports it as a plan gone stale: re-plan, and review what moved. MultiObjectError(message: 'str', errors: 'dict[str, Exception]', *, report: 'object' = None) -> 'None' Aggregates per-object failures from a fan-out operation. ``report`` is what the operation did before it gave up, when it did anything (`apply_gc`, `apply_promote`): the rest landed, and the caller should say so, not just that something failed. ---------------------------------------------------------------------- This is the CLI documentation for the package. ---------------------------------------------------------------------- ## CLI: tether ``` Usage: tether [OPTIONS] COMMAND [ARGS]... jj-style version control for heterogeneous datasets. Options: --help Show this message and exit. Commands: abandon Drop dataset commits from VCS history and see what... add Register an object in the working copy. backends List backend kinds with their maturity, tier, and... commit Pin mutable objects and record their state in the... diff Show object-level manifest differences between two... drop Throw a bookmark away: its commits, the bookmark, and... export [experimental] Write tether history as relational... forget-workspace Forget a workspace: its state files and the VCS... gc Release native pins that no manifest in VCS history... import [experimental] Register or sync objects from a... init Initialize a tether dataset in an existing git/jj... log List an object's native history (snapshots, versions,... new Start working on a bookmark: its store branches... open Print a native handle for an object (address on stdout). ops Show this workspace's operation log (what tether did... promote Land this bookmark on the trunk: move each system's... publish [experimental] Upsert the export tables into a... pull Fetch the heads of this bookmark's branches and... recover Find what a lost dataset left in its stores, and how... remove Unregister an object (does not touch the external... repair Recreate missing pins and working branches from the... restore Reset an object's working branch to what an older... set Change an object's policy in place. snapshot Fingerprint every object (or KEY...) and cache the... status Classify every object against its committed manifest. undo Reverse an operation where the stores still allow it. upgrade Bring a dataset made by an older tether up to this... verify Check that recorded states and pins still resolve. ``` ### tether abandon ``` Usage: tether abandon [OPTIONS] REVS... Drop dataset commits from VCS history and see what that frees. `jj abandon` / a git rebase, with one difference: later commits keep their manifests exactly as they were (a manifest records a whole state, so it must not conflict with the removal of an earlier one). Then the `gc` plan: pins that only the dropped commits referenced. Without `--gc` nothing is released -- run `tether gc --no-dry-run` when ready (undoing the abandon in jj/git first brings the pins back into use). Not undoable by tether. Options: --gc Also release the pins only those commits referenced. --json Machine-readable output. --help Show this message and exit. ``` ### tether add ``` Usage: tether add [OPTIONS] [KEY] [LOCATOR] Register an object in the working copy. The positional LOCATOR is stored as the `uri` field; named options set other locator fields. For a kind whose locator is a path, a lone positional is the LOCATOR and the key comes from it (`tether add s3://lab/greetings.icechunk --kind icechunk` registers `greetings`), unless `--set uri=` or `--set path=` gives the path; for any other kind it is the KEY. The first commit pins the upstream branch head (or --at / --pick: a chosen native state); after that the object keeps its pin until `tether pull` or a working branch moves it. Nothing is contacted until the next status/commit (except --pick, which lists history first). Options: --kind TEXT Backend kind: delta, file, git, icechunk, lance; experimental: dolt, ducklake, iceberg, neon. [required] --project-id TEXT Neon project. --database TEXT Neon/Dolt database. --role TEXT Neon role for connections. --branch TEXT Upstream branch (default main) for branching backends: what the trunk bookmark stands for -- `pull` reads it, `promote` lands on it. --remote TEXT git remote to push pins to. --region TEXT Object-store region. --host TEXT Dolt server host. --port INTEGER Dolt server port. --table TEXT DuckLake table scope. --set TEXT Extra locator field key=value (repeatable). --file TEXT file backend: immutable (drift is an error) or versioned (Addressable via object-store version ids). [default: immutable] --pin TEXT native (default: create a tag/branch per commit) or record (no native ref; forks come from the recorded state while the system retains it). [default: native] --at TEXT Start at this native state (snapshot id, version, commit, or tag) instead of the branch head: the first commit pins it; a later `pull` on the trunk moves on. --create Make an empty store at the locator first (backends with CREATE) and mark it as this dataset's; `gc --delete- stores` removes it again once nothing references it. Refused if anything already exists there. On a bookmark the store's working branch is forked at once (no `new` needed). --pick List the system's history and choose the base state interactively. --help Show this message and exit. ``` ### tether backends ``` Usage: tether backends [OPTIONS] List backend kinds with their maturity, tier, and capabilities. Options: --json Machine-readable output. --help Show this message and exit. ``` ### tether commit ``` Usage: tether commit [OPTIONS] [KEYS]... Pin mutable objects and record their state in the manifests. Pinnable/Forkable objects get a native ref `tether.`; Addressable and `pin = "record"` objects are recorded; Observed objects are recorded as unrecoverable. Unchanged objects are skipped. Then the manifests are committed. `--dry-run` / `--plan` preview the actions; `--from-plan` applies a saved plan after checking nothing changed underneath it. KEY... commits only those objects, like `git commit PATH`: only they are fingerprinted and pinned, and only their manifests and listings are committed. Every other manifest, and any other `.tether/` edit (another object's add or remove, a tether.toml change), stays uncommitted. Options: -m, --message TEXT VCS commit message (required unless --from-plan). --no-vcs Write manifests but skip the jj/git commit. --strict Fail instead of recording Observed objects unrecoverably. --force Skip quiescence checks (e.g. active Neon writers). --no-snapshot Commit the cached fingerprints as-is instead of fingerprinting first. --dry-run Show what would be pinned/recorded; write nothing. --plan PATH Write the plan to FILE (implies --dry-run). --from-plan PATH Apply a plan saved with --plan instead of replanning. --json Machine-readable output. --help Show this message and exit. ``` ### tether diff ``` Usage: tether diff [OPTIONS] [REV_A] [REV_B] Show object-level manifest differences between two revisions. With --content, changed objects whose backend supports it are diffed natively (files, tables, arrays, fragments, commits) using metadata only. `--key` limits the diff, and the stores it reads, to those objects. Options: --key TEXT Only this object: a key, or a prefix ending in '/' (zarr/); repeatable. Default: every object. -c, --content Also describe what changed inside each object. --limit INTEGER Max entries shown per object. [default: 20] --json Machine-readable output. --help Show this message and exit. ``` ### tether drop ``` Usage: tether drop [OPTIONS] BOOKMARK Throw a bookmark away: its commits, the bookmark, and its store leftovers. The opposite of `promote`, in one step: leave the bookmark if this checkout is on it (`--to`, default the trunk), drop the commits only it reaches, delete it, then release what it held in the stores -- `gc --prune-bookmarks` for this bookmark alone, under its rules (a branch with unpinned writes is kept unless `--force-prune`; `--delete-stores` adds the experimental created-store step). Refused for the trunk and for a bookmark another live checkout works on. Dry-run by default: the plan shows the store side as it will be once the commits are gone. Not undoable by tether (`jj undo` / the reflog bring the commits back; `repair` the pins and branches). Options: --to TEXT Where to leave for when this checkout is on the bookmark (default: the trunk). --delete-stores Also reclaim stores created on it (experimental; see `gc`). --force-prune Delete its branches even when they hold unpinned writes. Data on them is lost. --dry-run / --no-dry-run Only show the plan (default). --plan PATH Write the plan to FILE (implies --dry-run). --from-plan PATH Apply a plan saved with --plan. --json Machine-readable output. --help Show this message and exit. ``` ### tether export ``` Usage: tether export [OPTIONS] PATH [experimental] Write tether history as relational tables for registries. Tables: commits, commit_parents, refs, objects, object_states, plus listings / listing_entries (--listings) and workspace (--workspace); see the Registries guide for the schema. Query the SQLite file directly or `ATTACH` it in DuckDB; the parquet/csv/jsonl directories get one file per table and a schema.json. Options: -r, --rev TEXT Export only these revisions (repeatable). --all-history / --no-all-history Export every reachable commit (default when no --rev is given). [default: all-history] --format TEXT sqlite (default), parquet, csv, or jsonl. [default: sqlite] --listings Include per-file listings (listings, listing_entries). --workspace Include this checkout's working refs and snapshot. --append sqlite: upsert into an existing file instead of replacing it. --json Machine-readable output. --help Show this message and exit. ``` ### tether forget-workspace ``` Usage: tether forget-workspace [OPTIONS] [WORKSPACE_ID] Forget a workspace: its state files and the VCS checkout. `jj workspace forget` / `git worktree remove` plus tether's half in one step: the workspace's `workspace.toml` and `ops.jsonl` are removed and the VCS stops tracking the checkout (git's main worktree is left; jj leaves the directory). Store branches belong to bookmarks, not workspaces, so none are touched -- delete the bookmark and `gc --prune-bookmarks` for that. Forgetting the current workspace means the next tether command here starts a fresh one. Exit code 3 if a step failed. Options: --dry-run Show the plan; write nothing. --plan PATH Write the plan to FILE (implies --dry-run). --from-plan PATH Apply a plan saved with --plan instead of replanning. --json Machine-readable output. --help Show this message and exit. ``` ### tether gc ``` Usage: tether gc [OPTIONS] Release native pins that no manifest in VCS history references. Only pins this clone created are released; any other unreferenced pin is kept and listed as `keep-pin` (`--release-foreign` releases those too). Also forgets this workspace's refs for removed objects and deletes unreferenced listings. `--prune-bookmarks` evaluates stray `tether.ws.*` branches -- those of bookmarks that no longer exist (bookmarks the VCS has or a live checkout works on are kept), legacy per-workspace branches, and this bookmark's unused ones: a branch is deleted only if its head is pinned or equals the base head, otherwise kept -- `--force-prune` deletes those too. `--delete-stores` also reclaims stores this dataset created (`add --create`) that nothing references any more, once only tether's own refs remain in them (`delete-store`). Dry-run by default: pass `--no-dry-run` (or `--from-plan`) to release. Refused while jj reports a conflicted bookmark, or a conflict in `.tether/` no later commit resolved. When some actions fail, what the rest did is printed with the failures; exit code 3 if anything was released. Options: --dry-run / --no-dry-run Show the plan (default) or apply it. --prune-bookmarks Also delete `tether.ws.*` branches of bookmarks that are gone, legacy per-workspace branches, and this workspace's unused ones. --keep-bookmark TEXT Bookmark whose branches --prune-bookmarks must keep although the VCS here does not have it (e.g. it lives on another machine); repeatable. Bookmarks the VCS has, or a live checkout works on, are kept automatically. --force-prune With --prune-bookmarks: delete stray branches even when they hold unpinned writes, a pin-less recorded state, or are the storage itself (Neon). Data on them is lost. --delete-stores Also reclaim stores this dataset created (`add --create`) that nothing references any more: their pins and branches are released and, when only tether's own refs remained, the store is deleted. Irreversible; fetch every bookmark first. --store TEXT KIND=LOCATOR (repeatable): a created store to consider even when this clone's index does not have it -- its creator's clone is gone. Same rules (the owner marker in the store must name this dataset); implies --delete-stores. --release-foreign Also release unreferenced pins this clone did not create. By default they are kept and listed (`keep-pin`): another clone's commits, not fetched yet, may name them. --plan PATH Write the plan to FILE (implies --dry-run). --from-plan PATH Apply a plan saved with --plan. --json Machine-readable output. --help Show this message and exit. ``` ### tether import ``` Usage: tether import [OPTIONS] SOURCE [experimental] Register or sync objects from a registry query. Rows carry `key`, `kind`, and any of `uri`, `locator_json`, `policy_file`, `policy_pin`, `at`; write the mapping from your registry's columns in SQL. Import changes what is tracked (manifests only) -- run `tether commit` afterwards to record states. `--sync` removes objects the source no longer lists. Options: --table TEXT SQL sources: read every row of this table. --query TEXT SQL sources: run this query (must yield key, kind, and locator columns). Defaults to [import] query in .tether/secrets.toml. --sync Also remove registered objects the source does not list. --dry-run Show the add/update/remove plan; write nothing. --plan PATH Write the plan to FILE (implies --dry-run). --from-plan PATH Apply a plan saved with --plan instead of re-reading. --json Machine-readable output. --help Show this message and exit. ``` ### tether init ``` Usage: tether init [OPTIONS] [PATH] Initialize a tether dataset in an existing git/jj repository. Options: --dataset-id TEXT Use this dataset id (8 lowercase hex) instead of a fresh one. A dataset whose repository was lost takes back the pins and working branches its stores still hold under it; `tether recover` finds it. --json Machine-readable output. --help Show this message and exit. ``` ### tether log ``` Usage: tether log [OPTIONS] TARGET List an object's native history (snapshots, versions, commits), newest first. Entry ids are valid values for `tether add --at`; native branches, tags, and tether pins pointing at an entry are shown in brackets. Options: --kind TEXT Browse an unregistered object of this backend kind. --ref TEXT Start from this branch/tag/id (default: working ref or base). -n, --limit INTEGER Max entries. [default: 20] --branch TEXT With --kind: base branch. --set TEXT With --kind: extra locator field key=value (repeatable). --json Machine-readable output. --help Show this message and exit. ``` ### tether new ``` Usage: tether new [OPTIONS] [REV] Start working on a bookmark: its store branches become your working refs. `tether new -b NAME [REV]` creates bookmark NAME (at REV, default here) and forks a `tether.ws..NAME` branch per Forkable object off its pins -- lazily, on the first writable `open`, or now with `--eager`. pin=record objects fork now regardless, unless `--defer-record`. `tether new NAME` joins an existing bookmark; `tether new main` (the trunk) writes straight to every object's upstream branch; `tether new REV` with no bookmark there is read-only. A bookmark another live checkout holds is refused unless `--shared`. A working branch that holds writes you never committed is not reset unless `--discard`; `--adopt` takes the bookmark's existing branches as they are instead. `--dry-run` / `--plan` preview; `--from-plan` applies a saved plan. Options: -b, --bookmark TEXT Create this bookmark at REV (default: here) and work on it: one store branch per Forkable object, named after it. --shared Work on a bookmark another live checkout already holds (both then write the same store branches). --keep Keep current working refs; only refresh the baseline. --eager / --lazy Create every working branch now (--eager) or on the first writable open (--lazy). Default: [new] fork in tether.toml (lazy). --defer-record / --no-defer-record Leave pin=record objects for the first writable open too. Until then nothing of tether's holds their recorded state: once the store's retention expires it, reading them here fails as well. For bookmarks thrown away or merged sooner (a PR preview). Default: [new] record in tether.toml (fork now). --discard Reset working branches even if they hold writes that were never committed (otherwise such a new is refused). --adopt Take the bookmark's existing store branches (tether.ws.., its highest .N generation where there are several) as they are, whatever they hold -- the branches of a dataset whose repository was lost (`tether recover`). Objects with no such branch fork as usual. Not on the trunk. --dry-run Show which branches would be forked; write nothing. --plan PATH Write the plan to FILE (implies --dry-run). --from-plan PATH Apply a plan saved with --plan instead of replanning. --json Machine-readable output. --help Show this message and exit. ``` ### tether open ``` Usage: tether open [OPTIONS] KEY Print a native handle for an object (address on stdout). Without --rev, Forkable objects open writable at their working ref and everything else read-only at the base. A connection URL is printed with its password redacted unless --with-password (for `psql "$(tether open db --with-password)"`); --json is always redacted. Options: -r, --rev TEXT Open read-only at this revision's pinned state (default: $TETHER_REV). --read-only / --writable Force read-only or writable. --with-password Print a connection URL with its password (Neon); by default the password is redacted. Never honoured under --json. --json Machine-readable output. --help Show this message and exit. ``` ### tether ops ``` Usage: tether ops [OPTIONS] Show this workspace's operation log (what tether did to the stores). Every store-writing command appends an entry: the plan it applied, what it created or deleted, and what it replaced. The log is per workspace and untracked, like jj's own `op log`. `tether undo ID` reverses an entry where the store still allows it. Options: -n, --limit INTEGER Show the newest N entries. [default: 20] --json Machine-readable output. --help Show this message and exit. ``` ### tether promote ``` Usage: tether promote [OPTIONS] [KEYS]... Land this bookmark on the trunk: move each system's upstream branch to what the bookmark's branch holds, then move the trunk bookmark. The dataset commit already pins the branch's state, so nothing needs re- committing after a fast-forward; `promote` makes `main` in each system point at it too, and when everything fast-forwarded, sets the trunk bookmark to this bookmark's commit. Base unchanged since the fork -> fast-forward. Base moved -> native 3-way merge where the system has one (Dolt, git), otherwise refused with the system's own recipe; after a merge, `commit` then `promote` again to move the trunk. Exit code 1 when something was refused or failed and nothing landed, 3 when some of it landed. Options: -r, --rev TEXT Promote the states pinned at this dataset commit instead of this workspace's working branches. --strategy TEXT auto: fast-forward when the base is unchanged, else merge; ff: refuse anything but a fast-forward; merge: refuse anything but a merge. [default: auto] -m, --message TEXT Merge message for systems that record one. --dry-run Show per-object outcomes; write nothing. --plan PATH Write the plan to FILE (implies --dry-run). --from-plan PATH Apply a plan saved with --plan. --json Machine-readable output. --help Show this message and exit. ``` ### tether publish ``` Usage: tether publish [OPTIONS] [experimental] Upsert the export tables into a Postgres schema. Idempotent and incremental: commits already present are skipped, `refs` and `tether_meta` are refreshed. Run it after each `tether commit` (or from CI) so a registry can join `objects.uri` / `objects.commit_id` against its own tables. The DSN never comes from tether.toml. Options: --to TEXT Postgres DSN (postgresql://...). Defaults to $TETHER_PUBLISH_DSN. --schema TEXT Target schema name. [default: tether] -r, --rev TEXT Publish only these revisions (repeatable). --listings Include per-file listings (listings, listing_entries). --dry-run Show what would be written per table; write nothing. --json Machine-readable output. --help Show this message and exit. ``` ### tether pull ``` Usage: tether pull [OPTIONS] [BOOKMARK] Fetch the heads of this bookmark's branches and commit them onto it. The dataset's `git fetch` + rebase. On `main` (the trunk) that is every object's upstream branch -- and, for systems without branches, the object itself; a table's current version, a directory's contents. What differs from the bookmark's commit is pinned and committed on the bookmark, which moves; the working copy ends up on top. Nothing moved: no commit. An immutable file that changed is refused. `undo` uncommits it (pins stay). `--key` pulls only those objects, and commits only their manifests, as `commit KEY...` does. Options: --key TEXT Pull only this object: a key, or a prefix ending in '/' (zarr/); repeatable. Objects sharing a native branch space are named together. -m, --message TEXT Commit message (default: pull B: N objects). --json Machine-readable output. --help Show this message and exit. ``` ### tether recover ``` Usage: tether recover [OPTIONS] [KEYS]... Find what a lost dataset left in its stores, and how to take it back. Read-only. Lists tether's refs in each registered object's store -- pins `tether..` and working branches `tether.ws..` -- by the dataset id they carry, marks the one tether.toml names, and prints the steps, each a command (values quoted for the shell) or none, and a `#` note: `repair` of the objects whose manifest names a pin their store lacks, the id to set while nothing is pinned under this one, a `commit` on the trunk, then `new --adopt -- BOOKMARK` (`new -b BOOKMARK --adopt -- TRUNK` where the VCS has no such bookmark) and a `commit` per bookmark, and per legacy per-workspace branch a `restore --at` onto a bookmark `recover-` off the trunk, and a `commit`. Each command ends its options with `--`, so a key starting with `-` is not read as one. With KEYs no dataset id and nothing that pins anew is suggested, and `repair` names only those: a commit pins every object under the id; the steps end with `tether recover` on every object. Exit 1 if a store could not be listed. Options: --json Machine-readable output. --help Show this message and exit. ``` ### tether remove ``` Usage: tether remove [OPTIONS] KEY Unregister an object (does not touch the external system). Options: --help Show this message and exit. ``` ### tether repair ``` Usage: tether repair [OPTIONS] [KEYS]... Recreate missing pins and working branches from the manifests. A manifest promises "this pin names this state". If the native ref is gone (an undone `gc`, a ref deleted by hand) but the state is still reachable in the store, `repair` recreates it; a working branch this workspace expects but the store lost is forked again from the manifest. Pins that exist but point elsewhere are reported, not overwritten. Exit code 3 if something could not be rebuilt. KEY... repairs only those objects: only their pins and working branches are checked, and only their stores contacted. Options: --all-history Also check the pins of every commit in history. --dry-run Show what would be rebuilt; write nothing. --plan PATH Write the plan to FILE (implies --dry-run). --from-plan PATH Apply a plan saved with --plan instead of replanning. --json Machine-readable output. --help Show this message and exit. ``` ### tether restore ``` Usage: tether restore [OPTIONS] KEYS... Reset an object's working branch to what an older commit pinned. The per-object `jj restore --from REV`: only KEY's branch moves; the rest of the workspace and the manifests stay. The object is not stale afterwards -- the next `commit` pins what you restored -- and `promote` treats the branch as forked from REV. Refused if the branch holds writes you never committed, unless `--discard`. `--at REF` starts the branch from a native ref or state instead, such as a branch made outside tether: it is resolved when the plan is made, and the bookmark's own branch is forked from that state. The object then reads as modified until the next `commit` pins it. Refused on the trunk bookmark. Options: -f, --from TEXT Revision whose pins to restore. --at TEXT Instead of --from: start the branch from this native ref or state in each object's store (a branch, tag or state id, as `add --at` takes). The branch is a copy; REF itself is never moved or written. --discard Reset even if the branch holds uncommitted writes. --dry-run Show the plan; write nothing. --plan PATH Write the plan to FILE (implies --dry-run). --from-plan PATH Apply a plan saved with --plan instead of replanning. --json Machine-readable output. --help Show this message and exit. ``` ### tether set ``` Usage: tether set [OPTIONS] [KEYS]... Change an object's policy in place. Manifest-only, logged, undoable. Commit afterwards to record the policy. Whether writes fork or land upstream is not a policy: it is the bookmark you are on (`tether new`). Options: --file TEXT immutable | versioned (file objects). --pin TEXT native | record: a native ref per commit, or state only. --all Every registered object. --json Machine-readable output. --help Show this message and exit. ``` ### tether snapshot ``` Usage: tether snapshot [OPTIONS] [KEYS]... Fingerprint every object (or KEY...) and cache the result in the workspace. The cached states of objects not named stay as they were. Options: --json Machine-readable output. --help Show this message and exit. ``` ### tether status ``` Usage: tether status [OPTIONS] [KEYS]... Classify every object against its committed manifest. Local by default: the manifests, working refs, staleness, and the *last* snapshot of each object's state, with its age. `--snapshot` fans out and fingerprints every object first (one metadata round-trip per system; a suspended Neon compute wakes). A workspace with no snapshot yet takes one. Labels: new (never committed), modified, clean, error (its store could not be read, now or by the last snapshot; exit 1). `(STALE)` means the committed state changed since this workspace forked; run `tether new`. KEY... limits all of it to those objects: only they are contacted. Options: --snapshot / --no-snapshot Fingerprint every object now (--snapshot) or show the last snapshot and its age (--no- snapshot). Default: [snapshot] auto in tether.toml (off: show the last snapshot). --json Machine-readable output. --help Show this message and exit. ``` ### tether undo ``` Usage: tether undo [OPTIONS] [OP_ID] Reverse an operation where the stores still allow it. commit/pull: uncommit (manifests become working-tree changes; pins stay). new/fork: delete the branches it *created* and restore workspace.toml and the VCS working copy; branches it reset are reported with their old head (`restore` / `new --discard` put them where you want). gc: restore the forgotten working refs and listings; deleted branches and pins are irreversible (see `repair`). import/add/remove/set: restore the manifests. promote: refused, with the previous base heads printed. drop, and the `new` and `gc` it runs: refused (the VCS's undo brings the commits back, `repair` the branches and pins). An older operation, named by id, gets back only the workspace fields it changed that no later operation changed again; the rest are listed as skipped. Exit code 3 when part of the work could not be reversed; the rest was. Options: --discard Delete the branches the operation created even if they gained writes. --json Machine-readable output. --help Show this message and exit. ``` ### tether upgrade ``` Usage: tether upgrade [OPTIONS] Bring a dataset made by an older tether up to this version. One migration (see `tether.upgrade`) whose parts run on what the dataset shows, not on the version it recorded; the new `[tether] version` is written once at the end. Where native refs predate the dataset namespace it renames them in every store and rewrites every historical manifest to match, so `gc` keeps seeing the same pins from both sides. Rewriting history changes commit ids: every other clone must re-sync afterwards. Run `--dry-run` first. A failed store rename stops the upgrade before anything else changes (renames already made are skipped on the next run); exit code 3 marks a partially applied step. Options: --dry-run Show what the upgrade would do; write nothing. --plan PATH Write the plan to FILE (implies --dry-run). --from-plan PATH Apply a plan saved with --plan instead of replanning. --ignore-immutable jj: also rewrite commits jj marks immutable (pushed history). --json Machine-readable output. --help Show this message and exit. ``` ### tether verify ``` Usage: tether verify [OPTIONS] [KEYS]... Check that recorded states and pins still resolve. Reports ok, drifted, missing, or unknown per object; exits 1 if anything is not ok. KEY... verifies only those objects (with --all-history, at every commit that has them). Options: -r, --rev TEXT Verify the manifests at this revision. --all-history Verify every commit in the repository. --deep Actually open recorded states (resolves `unknown`). --json Machine-readable output. --help Show this message and exit. ``` ---------------------------------------------------------------------- This is the User Guide documentation for the package. ---------------------------------------------------------------------- ## Start Here ### Overview tether is version control for datasets that live in several systems at once: an Icechunk repository, a directory of raw files, a Postgres database, the code that produced them. It keeps one small manifest per object in a git or jj repository. A commit of those manifests is a reproducible position for every object. Where the system allows it, tether *pins* that position with a native ref so it stays readable, and *forks* a writable branch off it when you want to change things safely. ## The loop ```bash tether init # inside a git or jj repository tether add zarr/imaging --kind icechunk s3://bucket/imaging.icechunk tether add raw/plate1 --kind file s3://bucket/raw/plate1/ tether commit -m "Baseline" # record every object; pin what can be pinned tether new -b relabel # a branch in every system, forked on first write python relabel.py # write through repo.open("zarr/imaging") tether commit -m "Relabel plate1" # pin the branch heads; move the bookmark tether promote # land it on main, in every system at once ``` Reading back is one flag: `tether open zarr/imaging --rev ` (or `TETHER_REV=` for a whole job) hands you the system's native handle at exactly the state that commit recorded. ## Where to go | You want to | Read | | --- | --- | | Run it in ten minutes, on local data | [Getting Started](01-getting-started.qmd) | | Understand the model and how it maps onto jj | [Concepts](02-concepts.qmd) | | Record state and reproduce reads later | [Pinning](03-pinning.qmd) | | Change data on a branch, then land or discard it | [Branching and Writing](04-branching-and-writing.qmd) | | Keep the storage bill down; preview store writes as plans | [Reclaiming Storage](05-reclaiming-storage.qmd) | | See the whole loop run, with real output | [Worked Examples](06-use-cases.qmd) | | Look up a command's behaviour, exit codes, JSON | [CLI Guide](07-cli.qmd) | | Configure a dataset; keep credentials out of it | [Configuration](08-configuration.qmd) | | Check what a backend can and cannot do | [Backends](09-backends.qmd), [Caveats and Performance](12-caveats-and-performance.qmd) | | Publish history as SQL, or import objects from a registry | [Registries and SQL](10-registries-and-sql.qmd) | | Add a system tether does not know yet | [Writing a Backend](11-extending.qmd) | | Hand a dataset to a teammate, or run it in CI | [Sharing and CI](13-sharing-and-ci.qmd) | | Read an error and find the way out; remove tether from a repository | [Troubleshooting](14-troubleshooting.qmd) | The generated **Reference** (top navigation) has every CLI flag and every Python symbol. ## Vocabulary Eight words carry the whole guide. | Term | Meaning | | --- | --- | | **object** | One thing tether tracks, under a key such as `zarr/imaging`: an Icechunk repository, a directory or object-store prefix, a Neon database, a git repository, ... | | **dataset** | The git/jj repository holding one manifest per object. A **dataset commit** is a VCS commit of those manifests: a position for every object at once. | | **state** | An object's fingerprint right now -- a snapshot id, an LSN, a content digest. Comparing states is how drift is found. | | **pin** | A durable native ref (`tether..`: an Icechunk tag, a Neon branch, a git tag) that tether creates at commit time so a recorded state cannot be expired or garbage-collected. | | **bookmark** | A jj bookmark or git branch of the dataset. It stands for one branch per system. | | **trunk** | The `main` bookmark. It stands for each object's **upstream branch** -- the branch the object had before tether -- so working on the trunk writes there. | | **working ref** | The store branch this checkout writes through: the upstream branch on the trunk, `tether.ws..` on any other bookmark. | | **fork** | A writable branch created off a pin, the first time a bookmark writes to an object. | Backends fall into four **tiers**: *Observed* (fingerprint only), *Addressable* (re-read later by id), *Pinnable* (a native ref holds it), and *Forkable* (a branch can be made from it). [Backends](09-backends.qmd) lists every system's tier. ### Getting Started Ten minutes, no cloud account: a local Icechunk repository and a directory of files, registered in a fresh repository, committed, changed on a branch, and landed. Every command below was run as shown; ids will differ on your machine. ## Install ```bash pip install "tether-vcs[cli,icechunk]" # what this page needs pip install "tether-vcs[cli,icechunk,neon]" # add the backends you use pip install "tether-vcs[all]" # everything ``` The distribution is `tether-vcs` (PyPI reserves the bare name); the package you import and the command you run are both `tether`. Python 3.11 or newer. Extras per backend: `objectstore` (S3/GCS/Azure for `file`; `s3`, `gcs`, `azure` are aliases), `icechunk`, `neon`, `iceberg`, `delta`, `lance`, `ducklake`, `dolt`. ::: {.callout-note title="Before you start"} - git 2.38+ or jj 0.43+ on `PATH`: tether keeps its history in *your* repository. - A POSIX system or WSL: on Windows, commands that write are refused. - Remote objects need credentials, which never go in the repository ([Configuration](08-configuration.qmd#trust-boundary-tethersecretstoml)). ::: ## Some data to track Use anything you have, or make a small sample: an Icechunk repository with one array, and a directory of three files. ```python import pathlib, icechunk, zarr data = pathlib.Path("data") storage = icechunk.local_filesystem_storage(str(data / "imaging.icechunk")) repo = icechunk.Repository.create(storage) session = repo.writable_session("main") root = zarr.create_group(store=session.store) root.create_array("labels", shape=(4, 8, 8), chunks=(1, 8, 8), dtype="u1")[:] = 0 session.commit("plate1: empty labels") raw = data / "raw" / "plate1" raw.mkdir(parents=True) for i in range(3): (raw / f"well_{i}.tif").write_bytes(bytes([i]) * 64) ``` ## Register and commit tether lives inside a git or jj repository. Make one next to the data: ```bash jj git init my-dataset && cd my-dataset # or: git init my-dataset tether init tether add zarr/imaging --kind icechunk ../data/imaging.icechunk tether add raw/plate1 --kind file ../data/raw/plate1/ tether status ``` ``` dataset 61d0206e8d81; on trunk bookmark main new raw/plate1 [file/observed] new zarr/imaging [icechunk/forkable] ``` `init` and `add` wrote small TOML manifests under `.tether/`, and put the working copy on `main`, the dataset's *trunk*. `status` was the first command to touch the data: it fingerprinted both objects. The header's `61d0206e8d81` hashes the manifests as they are now, so it changes with every edit; the dataset's permanent id is the `[dataset] id` in `tether.toml`. The bracket names each object's *tier*. Icechunk is *forkable*: it can be pinned and branched. A directory is *observed*: tether can tell you it changed, nothing more. ```bash tether commit -m "Baseline: plate1 imaging and raw files" ``` ``` pinned zarr/imaging -> tether.4e9503fc.73e341e966c83d38 recorded raw/plate1 (not recoverable) committed b55326ca55a0 ``` One command did three things. It created an Icechunk tag (the *pin*) holding the snapshot, wrote both states into the manifests, and committed them with the `main` bookmark on the commit. `b55326ca55a0` is now the handle for this exact combination of states. ## Read it back Any process can open an object *as it was* at a commit: ```bash tether open zarr/imaging --rev b55326ca55a0 ``` ``` /path/to/data/imaging.icechunk#tether.4e9503fc.73e341e966c83d38 ``` ```python from tether import Repo repo = Repo.find(".") h = repo.open("zarr/imaging", rev="b55326ca55a0") # read-only IcechunkHandle at the tag zarr.open_group(h.session.store, mode="r") ``` For a whole job, set `TETHER_REV=b55326ca55a0` and every `repo.open(key)` in it resolves read-only at that commit's pins -- no code change. The directory cannot be opened at a revision: its state is a digest, not an address, which is what `not recoverable` meant above. ## Change it on a branch A *bookmark* is a branch of the dataset and, with it, a branch in every system that has them. Start one: ```bash tether new -b relabel ``` ``` on bookmark relabel; working refs set up zarr/imaging -> tether.ws.4e9503fc.relabel (created on first writable open) ``` Nothing was written yet. The branch appears in the Icechunk repository the first time you open the object writable, forked from the pin `main` holds: ```python import zarr from tether import Repo repo = Repo.find(".") h = repo.open("zarr/imaging") # writable: this forks tether.ws.4e9503fc.relabel group = zarr.open_group(h.session.store, mode="a") group["labels"][:] = 1 h.session.commit("Relabel plate1") # an Icechunk commit on the branch ``` `main` -- the bookmark and the Icechunk branch -- is untouched. Record the result and compare it with the trunk: ```bash tether commit -m "Relabel plate1" tether diff main relabel --content ``` ``` pinned zarr/imaging -> tether.4e9503fc.89c7e63509153c4f committed 7eb84262ce1b ``` ``` changed zarr/imaging [+0 -0 ~1 nodes] modified /labels 4 chunks ``` `commit` pinned the branch head and moved the `relabel` bookmark onto the new commit. Both commits stay readable: `open --rev b55326ca55a0` still gives the zeros. ## Land it `promote` makes each system's upstream branch point at what the bookmark pinned, then moves the `main` bookmark to the commit. Like every planned command, it takes `--dry-run`, so look first: ```bash tether promote --dry-run ``` ``` plan: promote (1 write(s)) fast-forward zarr/imaging [icechunk] main main snapshot_id=SHS2X3EVBHMYV1BQ… -> snapshot_id=TY7E8S29CEP987JJ… (base unchanged since fork) raw/plate1: not forkable; nothing to promote ``` ```bash tether promote tether verify --all-history ``` ``` fast-forwarded zarr/imaging -> {'snapshot_id': 'TY7E8S29CEP987JJ1VX0'} skipped raw/plate1 main -> 7eb84262ce1b ``` ``` ok 1609e5dbca9d:zarr/imaging ok 7eb84262ce1b:zarr/imaging ok b55326ca55a0:zarr/imaging ``` Icechunk's `main` was fast-forwarded, the `main` bookmark now names the relabel commit, and `verify` confirms every pin any commit made still resolves. The third commit is jj's empty working-copy commit, with the same manifests. In CI, run `verify --all-history --deep` ([the recipe](13-sharing-and-ci.qmd#a-ci-recipe)). ## What was written Everything committed is small text, reviewable in `jj diff` or `git diff`: `tether.toml` and one manifest per object under `.tether/objects/`. The per-checkout files, `.tether/workspace.toml` (which bookmark you are on) and `.tether/ops.jsonl` (what tether did to the stores), are untracked. The full layout is in [Configuration](08-configuration.qmd#layout). ## Next Remote systems register the same way; only the locator changes. Backends marked *experimental*, like `neon`, have run against a fake of the service, not the service itself: ```bash tether add zarr/imaging --kind icechunk s3://bucket/imaging.icechunk tether add db/metrics --kind neon --project-id prj-123 --database neondb --role runner tether add features --kind lance s3://bucket/features.lance tether add code --kind git ../analysis-code ``` - [Pinning](03-pinning.qmd): commit, reproduce, catch drift, verify. - [Branching and Writing](04-branching-and-writing.qmd): bookmarks, native handles, `promote`, `pull`, `drop`, and what happens when a promote is refused. - [Worked Examples](06-use-cases.qmd): seven situations, run end to end with real output. - [Concepts](02-concepts.qmd): the model in one page, and where it maps onto jj. - [Sharing and CI](13-sharing-and-ci.qmd): hand the dataset to a teammate, and run `verify` in CI. ### Concepts tether borrows [jj](https://jj-vcs.dev)'s working-copy and operation-log ideas (`new REV` to start on top of a commit, the stale-working-copy check, an op log with `undo`). It applies them to external systems that keep their own history. This page is the model in one sitting; the [Overview](index.qmd#vocabulary) has the eight terms it uses. ## The working copy is the manifests A dataset is a directory holding `tether.toml` and one manifest per object, `.tether/objects/.toml`. A manifest names the object (`kind` and `locator`), its policy, and once committed its recorded `state` and `pin`. Committing a dataset commits those manifests to the enclosing git or jj repository, so history, branching, workspaces and sharing are the VCS's. What the VCS cannot see, what tether did to the *stores*, goes in tether's [operation log](08-configuration.qmd#operation-log). ## Commits hold pins; bookmarks hold branches Two things are versioned, in two places. A **dataset commit** records, for every object, an exact *state* and, where the system can hold one, a *pin*: a native ref that keeps that state alive. Nothing in a commit points at anything that moves: not a branch, not "latest", not a path's current contents. That is what makes `tether open KEY --rev C` a reproducible read months later, and what `verify` checks. A **bookmark** is where writes go. The working copy (`.tether/workspace.toml`, per checkout) records which bookmark it works on and, per object, the branch that stands for it: its *working ref*. | Working copy is on | Each Forkable object's working ref | Fingerprinted by `commit`? | | --- | --- | --- | | the trunk bookmark (`main`) | its upstream branch (`locator.branch`) | yes: the head is what the next commit pins | | another bookmark, branch created | `tether.ws..` in its system | yes | | another bookmark, branch not yet created (lazy) | pending: forked from the pin on the first writable `open` | no: until then it sits at the pin | | no bookmark | none: read-only | no | Objects whose system has no branches (files, Delta, DuckLake) have no working ref: they keep their pin until a trunk `pull` reads them. The rule is that `commit` contacts an object only if this checkout can have moved it, the way a file you did not edit stays as the parent commit had it. `locator.branch` is the object's *upstream*: what the trunk bookmark stands for, what `pull` reads, and what `promote` lands on. ## State, pin, fork For each object a backend implements three things, in increasing order of what the system must support: - **State**: what the object *is* now, such as an Icechunk `snapshot_id`, a Neon `(lsn, commit_xid, branch)`, a Delta `version`, or a file's sha256. Comparing states is how drift is detected without watching anything. - **Pin**: a durable native reference to a state (an Icechunk tag, a Neon child branch, a git, Lance or Dolt tag), named `tether..`. The hash is content-addressed from `(kind, locator identity, state)`, so re-committing an unchanged object is a no-op. The dataset id keeps `gc` inside this dataset's [namespace](08-configuration.qmd#dataset-id). - **Fork**: a writable branch created off a pin, or for `pin = "record"` objects off the recorded state. `tether new -b NAME` decides one per Forkable object, `tether.ws..`, created by the first writable `open` (or at once with `--eager`). ## Capability tiers Backends declare capabilities, and commands degrade explicitly by tier: - **Observed** (`FINGERPRINT`): drift detection only; `commit` records the state as `recoverable = false`. A local directory, an unversioned prefix. - **Addressable** (`+ ADDRESSABLE`): the recorded state can be read back later with nothing to create or collect. An object in a versioned bucket, a Delta version, a DuckLake snapshot. - **Pinnable** (`+ PIN`): a durable native ref can be created and deleted; `verify` and `gc` apply. - **Forkable** (`+ FORK`): a writable branch can be made off a pin, so `new` forks and `open` returns a writable handle. Icechunk, Neon, Iceberg, Lance, Dolt, git. Orthogonal flags such as `PROMOTE`, `MERGE`, `CONDITIONAL_REF` and `DIFF` are listed per backend in [Backends](09-backends.qmd), and defined in [Writing a Backend](11-extending.qmd#capabilities-decide-behavior). ## Handles, not data tether is never in the data path. `open` returns a typed handle carrying the native address (an Icechunk session on the right branch or tag, a Postgres URL, a `DeltaTable` at a version) and steps aside. You read and write with the system's own library. ## Store writes are plans Most commands that write to a store compute a read-only *plan*, then *apply* it; apply refuses (`StalePlanError`) if the world no longer matches what the plan saw ([Plans](05-reclaiming-storage.qmd#preview-before-you-write-plans)). ## jj or tether? Both work on the same repository, so the question comes up constantly. The rule: **an operation gets a tether command when it has two halves, one in the VCS and one in the stores, that must happen together.** If it only touches files and history, use jj or git directly. tether notices what it needs to: a moved working copy makes objects stale, and a vanished dataset commit shows in `status` and `ops`. The nouns map one to one. A jj *bookmark* stands for one branch per system. jj's *snapshot on every command* is tether's **snapshot** on demand: a concurrent fingerprint on `verify`, `status --snapshot` and `snapshot`, and of the bookmark's branches on `commit`. A *stale working copy* is a bookmark whose committed state changed after its branches were forked. A *detached head* is an object registered `--at` a state, until a trunk `pull` moves it onto the branch. A *revision with no bookmark* is a read-only working copy. | You want to | Use | Because | | --- | --- | --- | | record the dataset's state | `tether commit` (jj: `jj commit`) | pins the bookmark's branches, writes their states into the manifests, commits, and moves the bookmark | | start a line of work | `tether new -b NAME [REV]` (jj: `jj new -b`) | creates the bookmark *and* a branch per system named after it | | switch to a bookmark | `tether new NAME` (jj: `jj new `) | moves the working copy *and* adopts that bookmark's branches | | look at another revision without writing | `jj new REV` / `git switch` | nothing to fork; `status` says the working copy left the bookmark | | compare two positions | `tether diff [A] [B]` (jj: `jj diff`) | an object-level manifest diff; `--content` adds native diffs | | browse an object's own history | `tether log KEY` (jj: `jj log`) | the system's snapshots, versions or commits; the ids feed `add --at` | | take what upstream has now | `tether pull` on `main` (jj: `jj git fetch`) | reads every upstream branch and branch-less object, and commits what moved | | land a bookmark on `main` | `tether promote` (jj: `jj bookmark set main -r feature`) | moves each system's upstream branch, then the trunk bookmark | | throw a bookmark away | `tether drop NAME` | drops its commits and the bookmark, then releases its pins and branches | | reset one object to an older state | `tether restore KEY --from REV` (jj: `jj restore --from`) | the branch lives in a store; everything else stays | | drop dataset commits | `tether abandon REV` (jj: `jj abandon`) | descendants keep their manifests, and the freed pins are planned for `gc` | | describe, squash, rebase, split, merge | jj / git | manifests are files; run `tether gc` if commits disappeared, and `tether verify` and `tether new` after a merge | | rename or delete a bookmark | `jj bookmark rename/delete` | `status` then says so; `gc --prune-bookmarks` releases the old branches | | push, fetch, review | jj / git | [Sharing and CI](13-sharing-and-ci.qmd) | | open or finish a second checkout | `jj workspace add`; `tether forget-workspace` | each checkout is its own tether workspace; forgetting one leaves the bookmark's branches | | release what history no longer names | `tether verify`, then `tether gc` | pins and branches live in the stores | | undo tether's last operation | `tether undo` (jj: `jj undo`) | the VCS cannot see what tether did to the stores ([Undo](04-branching-and-writing.qmd#undo)) | | rebuild a pin or branch a manifest names | `tether repair` | manifests are the source of truth | | publish history as SQL | `tether export` / `publish` / `import` (experimental) | a derived view of the manifests | Two habits keep the halves aligned: `tether status` after any VCS operation that moved the working copy or changed manifests, and `tether new` before writing again. ### Where tether is not jj - **Writes need a bookmark.** A store branch must exist before a write can land, so the bookmark is the handle both sides share. Reads (`open --rev`, `status`, `verify`) work on anonymous changes, as in jj. - **`commit` moves the bookmark.** jj bookmarks do not follow commits; a tether bookmark must, or its branches' heads would stop matching the commit that names them. It happens in one jj operation, so `jj undo` takes both back. `commit` also starts nothing new. - **Snapshots are on demand.** Reading a state is a network round trip, so `status` is local, and `status --snapshot`, `verify` and `commit` fan out. - **The stores are remotes.** Pins are what commits reference, bookmark branches are local branches, `pull` is fetch, and `promote` is the push to `main`. ## What tether does not do - Sit in the data path, or run between commands: there is no daemon, and every state is compared when a command asks. - Cross-system atomicity: a commit records each state within a capture window, and point-consistency requires quiesced writers. - Reimplement history, branching or sharing, or query across systems. `ops`, `undo` and `repair` cover only what tether did to the stores. ## Core Workflows ### Pinning *Pinning* is recording the exact state of every object in a commit so it can be read back later -- and, where the system supports it, holding that state with a native reference that survives retention and cleanup. This page assumes [Getting Started](01-getting-started.qmd) and moves to the objects a lab actually has: an Icechunk repository on S3, a Neon Postgres database (an experimental backend), and a directory of raw plate images on a shared filesystem. ## Register the objects ```bash tether init tether add zarr/imaging --kind icechunk s3://bucket/imaging.icechunk --branch main tether add db/metrics --kind neon --project-id prj-9f2 --database metrics --role runner tether add raw/plate1 --kind file /mnt/instruments/plate1/ tether status ``` ``` dataset 0f2e9c7a11b3; on trunk bookmark main new db/metrics [neon/forkable] new raw/plate1 [file/observed] unrecoverable new zarr/imaging [icechunk/forkable] ``` `add` writes manifests only; `status` is the first command to contact the systems. `--branch main` (the default) names the object's *upstream* branch, and `--at SNAPSHOT` starts from a chosen state instead ([Starting from an older snapshot](04-branching-and-writing.qmd#starting-from-an-older-snapshot)). The tiers say what pinning will mean. Icechunk and Neon are *Forkable*, so a native ref will be created. The directory is *Observed*: its state can be recorded but not read back, hence `unrecoverable`. ## Pin: the first commit ```bash tether commit -m "Baseline: plate1 imaging + metrics" ``` ``` pinned zarr/imaging -> tether.0a1b2c3d.6674305d5e1fe5f6 pinned db/metrics -> tether.0a1b2c3d.a91f0e2b77c3e5f6 recorded raw/plate1 (not recoverable) committed 8d5719841a09 ``` | Object | State recorded | Pin created | | --- | --- | --- | | `zarr/imaging` | `snapshot_id` of `main` | Icechunk tag `tether.0a1b2c3d.6674305d5e1fe5f6` (immutable; the default `expire_snapshots` keeps tagged snapshots) | | `db/metrics` | `lsn`, `commit_xid` of `main` | Neon child branch `tether.0a1b2c3d.a91f0e2b77c3e5f6` at that LSN (protected only with `protected_pins = true`) | | `raw/plate1` | `count`, `size`, `digest` | none; the per-file listing is stored under `.tether/listings/` | Then the manifests (and the listing) were committed to your repository as `8d5719841a09`: ```bash jj diff -r 8d571984 # or: git show --stat 8d571984 ``` ``` Added regular file .tether/objects/zarr/imaging.toml: 1: key = "zarr/imaging" 2: kind = "icechunk" 3: recoverable = true ... 15: [state] 16: snapshot_id = "K3F7Q2ZJ1XN4Y8W0" 18: [pin] 19: id = "0a1b2c3d.6674305d5e1fe5f6" 20: ref = "tether.0a1b2c3d.6674305d5e1fe5f6" ``` Pin ids are content-addressed from `(kind, locator identity, state)`. Commit again without changing anything and every object is `unchanged`; no new refs are created. ## Pin again after the data moves Later, the imaging repository gets a new commit on `main` and the metrics database has been written to. On the trunk those upstream branches *are* the working refs, so a fan-out shows them modified: ```bash tether status --snapshot # fingerprint now; plain `status` shows the last snapshot ``` ``` dataset 0f2e9c7a11b3; on trunk bookmark main modified db/metrics [neon/forkable] clean raw/plate1 [file/observed] unrecoverable modified zarr/imaging [icechunk/forkable] ``` `commit` pins the new heads (`tether pull` does the same and writes the message for you): ```bash tether commit -m "Re-segmented plate1; metrics through 2026-09-04" ``` ``` pinned zarr/imaging -> tether.0a1b2c3d.deb4861eb388e5f6 pinned db/metrics -> tether.0a1b2c3d.0c31e7aa5f92e5f6 committed cfc66fa79c0a ``` Two commits now describe two complete states of the whole dataset. Compare them: ```bash tether diff 8d571984 cfc66fa7 --content ``` ``` changed db/metrics changed zarr/imaging [+0 -0 ~3 nodes] modified /labels 12 chunks modified /masks 12 chunks modified / group metadata ``` Neon has no native diff, so `db/metrics` shows only that it changed; Icechunk reports the arrays whose chunks were rewritten. ## Reproduce reads from a pinned commit This is the payoff. Any process can resolve every object *as it was* at a commit: ```bash tether open zarr/imaging -r 8d571984 tether open db/metrics -r 8d571984 ``` ``` s3://bucket/imaging.icechunk#tether.0a1b2c3d.6674305d5e1fe5f6 postgresql://runner@ep-quiet-lake-a1b2c3.us-west-2.aws.neon.tech/metrics?... ``` From Python the same handles come with native objects attached: ```python from tether import Repo from tether.handles import IcechunkHandle, NeonHandle repo = Repo.find(".") imaging = repo.open("zarr/imaging", rev="8d571984") assert isinstance(imaging, IcechunkHandle) and imaging.read_only store = imaging.session.store # read-only icechunk session at the tag metrics = repo.open("db/metrics", rev="8d571984") assert isinstance(metrics, NeonHandle) metrics.url # connection URL for the pinned branch ``` For reproducible jobs, set `TETHER_REV` instead of passing `rev` everywhere; `repo.open(key)` then defaults to a read-only handle at that commit: ```bash TETHER_REV=8d571984 python run_analysis.py ``` Record `TETHER_REV` (or `repo.vcs.current_rev()`) and each object's `pin.id` next to your results and any output traces back to the exact state of every input: ```python provenance = { "tether_rev": os.environ.get("TETHER_REV") or repo.vcs.current_rev(), "pins": {k: (m.pin.id if m.pin else None) for k, m in repo.objects.items()}, } ``` `raw/plate1` cannot be opened at a revision: it is Observed, and its state at `8d571984` is a digest, not an address. Directories that must be recoverable belong in an object store with versioning enabled (single objects with `--file versioned`). ## Drift `status --snapshot` fingerprints everything, so drift surfaces the next time you ask. The directory was registered with the default `--file immutable`, so if a file under `/mnt/instruments/plate1/` changes -- its *content*; a `touch` is not a change -- `status` labels it `error`, with the reason on stderr, and exits 1: ```bash tether status --snapshot ``` ``` error raw/plate1 [file/observed] unrecoverable error: raw/plate1: [raw/plate1] immutable object 'raw/plate1' changed since it was committed; re-register with acceptance to record the new state ``` That is deliberate: an "immutable" input silently changing is the worst kind of drift. Either the change is legitimate (`tether remove raw/plate1`, `add` it again, commit), or something wrote where it should not have. Other drift is benign and exits 0: a moved branch shows `modified` and is pinned by the next commit, and on the trunk a branch-less object past its pin shows `behind` until `pull`. ## Verify Pins are references *inside other systems*, and those systems have their own operators. `verify` confirms they still resolve to what the manifests say: ```bash tether verify # working-tree manifests, cheap checks tether verify -r 8d571984 --deep # actually open each pinned state tether verify --all-history --deep # every commit in the repository ``` ``` ok 8d5719841a09:db/metrics ok 8d5719841a09:zarr/imaging ok cfc66fa79c0a:db/metrics ok cfc66fa79c0a:zarr/imaging ``` A report is `ok`, `drifted` (the ref points elsewhere), `missing` (the ref or snapshot is gone), or `unknown` (cannot tell without `--deep`). `--all-history` verifies each distinct pin in history once. `verify` exits 1 if anything is not `ok`, `unknown` included, so the CI form is `verify --all-history --deep` ([A CI recipe](13-sharing-and-ci.qmd#a-ci-recipe)). A pin is never taken on trust. Before `open --rev`, `new` or `promote --rev` reads or forks from one, the engine checks it against the recorded state. A pin someone *deleted* falls back to the recorded state where the backend can address it, and `tether repair` recreates the ref. A pin someone *moved* is refused with `PinDriftError`, since it would hand back the wrong data under a commit's name. ## Garbage collection Pins hold storage. When history no longer names a pin (an aborted, squashed or abandoned commit), `tether gc` releases it; `tether gc` alone is a dry run, and `--no-dry-run` applies. Run `verify --all-history --deep` first: a pin that is referenced but broken is a `verify` finding, not a `gc` one. [Reclaiming Storage](05-reclaiming-storage.qmd) covers the rest. ## Next [Branching and Writing](04-branching-and-writing.qmd): change these objects on a bookmark, then land or discard the result. ### Branching and Writing Pinning makes the past reproducible; *branching* makes the present safe to change. `tether new -b NAME` starts a bookmark and, with it, a branch named after it in every system, off the pinned state. You write through native handles, and `commit` pins the results and moves the bookmark. Nothing you do on a bookmark touches `main` in any system until you `promote` it. This page continues from [Pinning](03-pinning.qmd). It adds a Lance feature store and the analysis code itself, so the recipe covers four systems. ## Register a few more objects ```bash tether add features --kind lance s3://bucket/features.lance tether add code --kind git ../analysis-code --remote origin tether commit -m "Add feature store and code" ``` `code` pins a *separate* git repository, the analysis code, at its `HEAD` sha (as a tag), so a dataset commit also names the code that produced it. Do not register the repository the dataset lives in: every `tether commit` moves its sha, so every trunk `pull` would find it moved and pin it again. ## A bookmark is a branch in every system: `tether new -b` `init` left the working copy on the trunk bookmark, `main`. There, every object's working ref is its *upstream* branch, such as `main` in Icechunk and in Neon, and `commit` pins wherever those are. To work without touching them, start a bookmark: ```bash tether new -b relabel ``` ``` on bookmark relabel; working refs set up code -> tether.ws.0a1b2c3d.relabel (created on first writable open) db/metrics -> tether.ws.0a1b2c3d.relabel (created on first writable open) features -> tether.ws.0a1b2c3d.relabel (created on first writable open) zarr/imaging -> tether.ws.0a1b2c3d.relabel (created on first writable open) ``` `jj log` now shows a `relabel` bookmark where `main` is, and every Forkable system will get a `tether.ws.0a1b2c3d.relabel` branch. The names match, so `jj log` and `icechunk branch list` tell one story. `new` decided the names and the fork points (the pins at `main`) and wrote nothing. Forking is **lazy** by default: the first writable `open` of an object creates its branch. A bookmark that reads three objects and writes one ends up with one branch, not four. | Object | Pin at `main` | Branch for `relabel` (created on first write) | | --- | --- | --- | | `zarr/imaging` | Icechunk tag | Icechunk branch `tether.ws.0a1b2c3d.relabel` | | `db/metrics` | Neon branch | Neon child branch `tether.ws.0a1b2c3d.relabel` | | `features` | Lance tag | Lance branch `tether.ws.0a1b2c3d.relabel` | | `code` | git tag | git branch `tether.ws.0a1b2c3d.relabel` | `0a1b2c3d` is the dataset's id (from `tether.toml`), so two datasets sharing a store never collide. Objects in one system share its branch. `raw/plate1` has no working ref: files cannot be forked. Three more forms of `new`: `tether new relabel` from another checkout joins the bookmark (refused while a checkout works on it, unless `--shared`), `tether new main` returns to the trunk, and `tether new REV` for a revision with no bookmark gives a read-only working copy. ### Lazy or eager `tether new --eager` (or `[new] fork = "eager"` in `tether.toml`) creates every branch during `new`. Every fork is a copy-on-write pointer, so the difference is not the cost of a branch. It is what an idle branch *holds* and *needs*: | | Lazy (default) | Eager | | --- | --- | --- | | Branches created | only for objects you actually write | one per Forkable object, every `new` | | What a forgotten workspace leaves behind | nothing for untouched objects | a branch per object, each keeping its snapshot alive; Neon counts them against the branch quota | | Credentials needed at `new` | read only | write, for every system | | First writable `open` | pays the fork (one metadata write and a pin check) | just opens | | `--dry-run` | shows `defer-fork`; the fork happens inside `open`, which has no dry run (`new --eager --dry-run` previews it) | shows every `fork` | | The pin is deleted before you write (`gc` on another machine) | `open` fails with `pin missing` | the branch already exists and keeps the data | | Pre-creating branches for a job | `Repo.materialize_fork(key)` per object, or `--eager` | automatic | If one system refuses to fork (quota, network), `new` still records the branches it created and names the object that failed. Run `new` again once the cause is gone; existing branches are reset onto the pin, never duplicated. **Pin-less objects are not deferred, unless you ask.** With `--pin record` there is no tag, only a snapshot id, version, commit or LSN the system will eventually expire. A branch on it is the only thing that stops that, so `new` forks such objects at once, even in lazy mode. `new --defer-record` (or `[new] record = "defer"`) defers them like the rest. That suits a bookmark that ends before the store could expire the state, such as a [PR preview](13-sharing-and-ci.qmd#a-pr-preview) that writes a few of many stores. Until a writable `open` forks it, nothing of tether's holds such an object's state. `new` warns per object, saying whether its upstream branch still sits at that state or only the store's retention keeps it, and `status` repeats the warning until the fork. Once the store expires the state, reading the object on that bookmark fails too, and so does the fork (`recorded state is gone`). `new --no-defer-record` forks them at once. A writable `open` never resets a branch on its own. It reuses one at the pin, or one a sibling object already writes through, and resets one only onto the head `new` reviewed. Otherwise it stops, so `new` can decide with the head in view. ## Write through native handles `open` without `-r` returns a **writable** handle for Forkable objects, positioned on the working ref. tether hands you the system's own objects and gets out of the way. ### Icechunk ```python import zarr from tether import Repo from tether.handles import IcechunkHandle repo = Repo.find(".") h = repo.open("zarr/imaging") assert isinstance(h, IcechunkHandle) and not h.read_only print(h.branch) # tether.ws.0a1b2c3d.relabel group = zarr.open_group(h.session.store, mode="a") group["labels"][...] = relabel(group["labels"][...]) h.session.commit("Relabel plate1 with model v3") # an Icechunk commit on the fork ``` ### Lance ```python import lance, pyarrow as pa from tether.handles import LanceHandle h = repo.open("features") assert isinstance(h, LanceHandle) lance.write_dataset(pa.table({"well": ["A1"], "score": [0.91]}), h.dataset, mode="append") ``` ### Neon (any Postgres client) ```python import psycopg from tether.handles import NeonHandle h = repo.open("db/metrics") assert isinstance(h, NeonHandle) with psycopg.connect(h.url) as conn: # URL for the forked branch's endpoint conn.execute("INSERT INTO metrics (well, score) VALUES ('A1', 0.91)") ``` Opening a Neon fork writable creates a `read_write` compute endpoint on the branch if none exists. Neon pins are *child branches* of the branch they were taken on, which shapes what `new` and `gc` can do afterwards; see [Caveats: Neon](12-caveats-and-performance.qmd#neon). ### Code ```python from tether.handles import GitHandle h = repo.open("code") assert isinstance(h, GitHandle) h.sha, h.path # the fork branch's head; commit your code changes there ``` From the shell, `tether open ` prints the same addresses: ```bash tether open db/metrics postgresql://runner@ep-brisk-forest-x9y8z7.us-west-2.aws.neon.tech/metrics?... tether open features s3://bucket/features.lance#tether.ws.0a1b2c3d.relabel@v4 ``` A handle stays on the branch it was opened on. Every new `open` follows the bookmark the checkout is on *now*, so open again after a `tether new` in another process. ## See what changed ```bash tether status --snapshot ``` ``` dataset 3ba1c07e55d2 modified code [git/forkable] modified db/metrics [neon/forkable] modified features [lance/forkable] clean raw/plate1 [file/observed] unrecoverable modified zarr/imaging [icechunk/forkable] ``` `main` in every system is untouched: the forks isolate your work. ## Commit: pin the branches, move the bookmark ```bash tether commit -m "Relabel plate1 with model v3; scores for A1" ``` ``` pinned code -> tether.0a1b2c3d.f01a9d3c2b7ee5f6 pinned db/metrics -> tether.0a1b2c3d.55e2ab7d9c10e5f6 pinned features -> tether.0a1b2c3d.9e7c4b2f0a61e5f6 pinned zarr/imaging -> tether.0a1b2c3d.deb4861eb388e5f6 committed 41c0d9e2b7f3 ``` Each pin points at the state of the bookmark's *branch*: an Icechunk tag on the branch's snapshot, a Lance tag on `(branch, version)`, a Neon branch at the branch's LSN, a git tag on the branch's sha. Then the manifests were committed and `relabel` moved onto the commit, in one jj operation. `main`, the bookmark and every system's branch, has not moved. Compare: ```bash tether diff main @- --content ``` ``` changed code [+0 -0 ~1 files] modified segment.py +12 -3 changed db/metrics changed features [+1 -0 ~0 fragments] added fragment 4 +1 rows changed zarr/imaging [+0 -0 ~1 nodes] modified /labels 12 chunks ``` Keep writing on the same branches and committing as often as you like. Each commit pins the latest state and moves the bookmark, and the earlier pins stay valid. `commit` is not `jj commit`: it starts nothing new. To record only some objects, name them, as with `git commit PATH`: `tether commit zarr/ db/metrics -m "..."` pins `db/metrics` and everything under `zarr/`. The other objects keep their last committed manifests, and a later `tether commit` picks them up. Objects that share a branch, such as two databases of one Neon project, are named together ([Selecting objects](07-cli.qmd#selecting-objects)). ## Landing it: `tether promote` Your dataset commit pins the bookmark's state in every system. Landing it means pointing each system's *upstream branch* at that state, then moving the trunk bookmark to your commit. Look at the plan first: ```bash tether promote --dry-run ``` ``` plan: promote (0 write(s)) hold code [git] main would fast-forward: main sha=41c0d9e2b7f3… -> sha=f01a9d3c2b7e… (base unchanged since fork); held because db/metrics, features refused, and a bookmark is planned whole or not at all refuse db/metrics [neon] main the neon backend cannot move a branch; Neon cannot promote a child branch into its parent; work on the trunk bookmark ... refuse features [lance] main the lance backend cannot move a branch; Lance cannot move a branch head; write the result onto the base branch ... hold zarr/imaging [icechunk] main would fast-forward: main snapshot_id=K3F9M2A1B7C8… -> snapshot_id=DEB4861EB388… (base unchanged since fork); held because db/metrics, features refused, and a bookmark is planned whole or not at all raw/plate1: not forkable; nothing to promote nothing moved: db/metrics, features refused; land the rest on purpose with `tether promote KEY...`, or write those systems' base branches by hand on the trunk (see the refusals) and promote again ``` For each Forkable object, tether compares the upstream branch with the **fork point**, the state recorded when the branch was created, and decides: | Upstream since your fork | Backend has | Outcome | | --- | --- | --- | | unchanged | `PROMOTE` | **fast-forward**: upstream moves to the branch's head; nothing is rewritten | | moved | `MERGE` | **merge**: the system's own three-way merge (Dolt, git); a conflict is reported and nothing is written | | moved | `PROMOTE` only | **refuse**, with the system's recipe (Icechunk and Iceberg have no merge) | | anything | neither | **refuse** (Neon, Lance) | What lands is what the bookmark's *commit* records, since the trunk moves to that commit. A branch with writes since the last commit is refused: `tether commit` them first. **A bookmark is planned whole or not at all.** When one system is refused and no keys were named, the others are held and nothing is written. A `main` new in one system and old in another is the half-done state you branched to avoid. Name keys to land a subset on purpose (`tether promote zarr/imaging code`): a refusal then holds nothing else, and the trunk bookmark never moves. A full promotion is refused outright when the trunk bookmark has commits yours lacks, or conflicting targets. The plan above lands nothing until you decide about `db/metrics` and `features`. Neon cannot promote a child branch, so a database that takes writes on `main` belongs on the [trunk](#working-on-the-trunk). Lance cannot move a branch head yet: write the result onto the base branch from the trunk. [Worked Examples](06-use-cases.qmd#reprocess-on-a-branch-then-land-it-or-throw-it-away) shows the two-step landing. **Applying.** Merges run first, and fast-forwards only once every merge landed, because a fast-forward cannot be taken back. Each base moves only from the head the plan reviewed ([conditional moves](07-cli.qmd#conditional-moves)). A backend that fails mid-apply leaves the systems before it landed and the trunk where it was; `promote` prints both, exits 3, and a re-run finishes the rest. When a base moved under the plan, the refusal says whether planning again can help. When everything **fast-forwarded**, the trunk bookmark moves to the commit the plan reviewed. After a **merge**, upstream holds a state no commit has pinned yet: `promote` resets your branch onto it (unless the branch gained writes meanwhile) and leaves the trunk. `tether commit` pins the merge, and a second `promote` moves the trunk: ```bash tether promote # fast-forward what it can, merge where the system can tether commit -m "Merge plate1 relabel into main" # only after a merge tether promote # ...then this moves main ``` `--strategy ff` refuses anything but a fast-forward, which suits CI, and `--strategy merge` refuses plain fast-forwards. `--rev REV` promotes what a *dataset commit* pinned, and never moves the trunk bookmark. ## Throwing it away: `tether drop` The other ending is one command too: `tether drop NAME`, the opposite of `promote`. It is a dry run by default, and the plan lists, in order: 1. `leave-bookmark`, when this checkout is on it: `new` to `--to` (default: the trunk). 2. `abandon-commit` for each commit that only this bookmark reaches. 3. `delete-bookmark`. 4. The store side: `gc --prune-bookmarks` for this bookmark's branches, the pins nothing references once the commits are gone, and with `--delete-stores` the experimental [created-store step](05-reclaiming-storage.qmd#stores-tether-created). One rule is `drop`'s own: a branch whose head a *dropped commit* pinned is deleted, since that is committed work you throw away by name. A plain `gc` would keep it as "unpinned writes". Writes no commit recorded are kept, and said so, unless `--force-prune`. `drop` is refused for the trunk, for a bookmark another live checkout works on, and while jj reports a conflicted bookmark or `.tether/` conflict. "Only this bookmark reaches" is exact: another bookmark, a tag, a remote bookmark (`NAME@origin`) or another workspace's working copy keeps the commits. The plan then deletes just the bookmark, and says what still reaches them. The store actions are a preview, computed as if the commits were gone; apply plans them again after the VCS half, and derives again which commits only the bookmark reaches. The plan goes stale when applied in another checkout, or once the bookmark, this checkout or history moved, or another checkout joined the bookmark. `tether abandon REV --gc` is the surgical form, for commits off a bookmark you keep. **A drop you regret** is not undoable by tether. Three steps, in order and before the next `gc`: 1. `jj undo`, or under git `git branch NAME ` from `git reflog`: the commits and the bookmark are back. 2. `tether repair --all-history` re-pins what those commits recorded, while the stores still have those states. 3. `tether new NAME` to work on it again, starting from its pins as a fresh fork; the branches `drop` deleted are gone. A drop interrupted part-way is finished, not undone: `tether undo` and `tether repair --dry-run` name the command that finishes it. ## Positions, and `tether pull` Between commits each object has a *position*: what the next commit will record. As with files in a VCS, what you did not touch stays as the last commit had it. - On the **trunk**, a Forkable object's position is its upstream branch head: `commit` fingerprints it and pins whatever is there. - On another **bookmark**, it is the bookmark's branch once that exists. Until the first write it sits at the pin the bookmark started from, and `commit` does not contact it. - Objects whose system has **no branches** (files, Delta, DuckLake) sit at their pin on every bookmark. So does an object registered `--at`, until a trunk `pull` moves it onto the branch. - An object with **no commit yet** is read once, at the branch head or at `--at`. `tether pull` is the fetch: it reads the heads of the bookmark's branches and commits what moved onto the bookmark. On the trunk that means every upstream branch and every branch-less object, such as a table's current version or a directory's contents. A nightly `tether pull` on `main` is the dataset trailing the world: ```bash tether new main tether pull ``` ``` pulled zarr/imaging snapshot_id=TYZ25DJEHXER -> snapshot_id=AZ1REKMV3HET pulled raw/plate1 digest=9f2e1c… -> digest=0b7a4d… up to date db/metrics committed 8d5719841a09 on main ``` When nothing moved, nothing is committed. A changed `file = "immutable"` object is refused: accepting it is a re-registration, not a pull. On the trunk, `status --snapshot` shows what a pull would take: `modified` for moved upstream branches, `behind` for branch-less objects and `--at` positions. ### Starting from an older snapshot `--at` sets an object's *initial* position to a state that was never pinned: an older Icechunk snapshot from before you adopted tether, a Lance version, a Dolt commit. `tether log` shows what there is to choose from, with each system's own ids, timestamps and messages: ```bash tether log zarr/imaging # a registered object tether log s3://bucket/imaging.icechunk --kind icechunk # before registering ``` ``` AZ1REKMV3HETD2X7EW7G 2026-09-05 15:16 Relabel plate1 with model v3 [main] TYZ25DJEHXERGBR7H3P0 2026-09-05 14:02 Segment plate1 M32R7C6M4H4Y8BEGSEK0 2026-09-05 13:40 Convert plate1 1CECHNKREP0F1RSTCMT0 2026-09-05 13:39 Repository initialized ``` ```bash tether add zarr/imaging --kind icechunk s3://bucket/imaging.icechunk --at TYZ25DJEHXERGBR7H3P0 tether commit -m "Adopt imaging at the pre-relabel snapshot" tether new -b from-pre-relabel ``` `--pick` does the same interactively: it numbers the list and asks which entry to use. The first `commit` pins that state, `open` reads it, and a bookmark forks from the pin, as if it had been the branch head. After that `at` has no role: a trunk `tether pull` moves the object onto `main` and drops `at` from the manifest. The per-system forms of `at` are in [Backends](09-backends.qmd); Neon and files have none. ### Starting from a branch made outside tether To start a bookmark from a branch someone made with the store's own tools, or from a tag or a state id, restore onto it on that bookmark: ```bash tether new -b try-theirs tether restore zarr/imaging --at their-experiment ``` The bookmark's branch (`tether.ws..try-theirs`) is forked from the state `their-experiment` names when the plan is made. It is a copy: `their-experiment` itself is never moved or written. The object reads as `modified` until the next `commit` pins it. `--at` takes what `add --at` takes for that backend (Lance: a tag or a version). A branch that holds writes nobody committed needs `--discard`. On the trunk `restore --at` is refused, because there the working ref is the upstream branch itself: register the object `--at` instead, or `tether pull`. ## Working on the trunk Some objects should never be forked: a production database, or a shared `main` you advance on purpose. That is what the trunk bookmark is for: ```bash tether new main tether open db/prod # a writable handle to main itself tether commit -m "Nightly load" ``` On the trunk every Forkable object's working ref is its upstream branch (`locator.branch`, `main` by default; `[vcs] trunk` names the bookmark). `open` returns a writable handle to it, and `commit` pins whatever it finds there: reproducibility without branch sprawl. Neon's quiescence check keeps those pins consistent (`commit` refuses while writers are active, unless `--force`). Two checkouts may both work on the trunk, which is what a shared `main` means. Any other bookmark is refused to a second checkout unless `--shared`. ## Stale working copies Your branch can end up forked from a state that is no longer the dataset's. A teammate committed on the same bookmark (`--shared`), or the bookmark was moved by hand to a revision where an object is pinned differently: ```bash tether status ``` ``` dataset 9d0e5f71ac22 (STALE: zarr/imaging) ... ``` ```bash tether open zarr/imaging error: working copy is stale: the committed state of zarr/imaging changed since this workspace forked; run `tether new` to refork before writing ``` The refusal covers the whole workspace, as jj's does: no writable handle and no `commit` until `new` decides every fork again. Read-only handles keep working. Your own commits never make you stale, and unrelated objects neither cause nor clear it. `tether new` decides again from the current pins and clears the flag. What you *committed* is safe, since those pins are tags. Uncommitted writes are not, because re-forking resets a branch onto the new pin. So `new` refuses to reset a branch holding writes beyond what you last committed, and says whose they are: ``` $ tether new error: refusing to reset working branches with unpinned writes: zarr/imaging: tether.ws.0a1b2c3d.relabel has writes since this workspace last committed (snapshot_id=Q7ZK…; this workspace's own); commit them, or pass --discard to throw them away ``` Commit, or `tether new --discard` if that was scratch work. When the writes are a peer's, on a bookmark you share, `--discard` would erase work that is not yours; `tether new --shared` adopts the branch as it is instead, even while the peer (or a service) keeps writing to it. `tether restore KEY --from REV` resets one object's branch and asks the same question, of the branch as the store has it. ## Undo Every command that touched a store is in this checkout's operation log (`tether ops`). `tether undo` reverses the newest entry where the store still allows it, deleting what the operation *created*: a commit is uncommitted (the pins stay), a `new` loses the branches it made. It never guesses where something it *reset* should go; that is a `restore` you choose. A created branch that gained writes is kept until you commit them or pass `--discard`. Per command: [the undo table](07-cli.qmd#ops-undo-repair). `jj undo` and `git reset` move the manifests, never the stores: use them for the VCS side and `tether undo` for tether's. tether's log holds deltas, not whole views, so several slips are several `undo`s, newest first. When the VCS goes around tether (a `jj undo`, `jj abandon` or `git reset` of a dataset commit), `status` warns per vanished commit, and `ops` flags its entry `(vcs commit gone)`. An *undone* commit leaves its manifests as edits: commit again. An *abandoned* one reverts them: `gc` releases its pins, and `repair` recreates them if it comes back. ## Workspaces and bookmarks Each jj workspace (or git worktree) is its own tether workspace, with its own untracked `.tether/workspace.toml` saying which bookmark it works on. Two checkouts on two bookmarks never write the same store branch. Two checkouts on *one* bookmark would, so `tether new NAME` refuses a bookmark another live checkout holds unless you pass `--shared`. The trunk is the exception: `main` is shared by nature. Separate clones share no lock and cannot see each other's checkouts ([Two clones, one bookmark](13-sharing-and-ci.qmd#two-clones-one-bookmark)). When the VCS moves a bookmark behind tether's back (`jj bookmark delete`, `rename` or `set`, or a `jj new` that leaves it), `tether status` says so, with what to do. `tether new NAME` returns or continues under the new name, `tether new -b NAME` recreates one, and `gc --prune-bookmarks` releases the branches of a bookmark that is gone. ## Next [Reclaiming Storage](05-reclaiming-storage.qmd): what pins and forks cost, how to preview store writes as plans, and how to release what history no longer names. ### Reclaiming Storage Version control for data is not free the way it is for code. A pin is a native ref, and a native ref holds storage: an Icechunk tag keeps every chunk its snapshot references, a Neon branch keeps its history window, and an Iceberg tag blocks snapshot expiry. Each commit adds one pin per changed object, and each fork adds a branch. This page is the home of two things: **plans**, which preview store writes before they happen, and the three levers that keep the storage bill in check. The rule underneath: **tether's VCS history is the reference set.** A pin, branch or listing stays alive while some manifest in some commit names it. Drop the commit, and `gc` releases what it held. ## Preview before you write: plans Most commands that write to a store are a read-only *plan* followed by an *apply* ([the list](07-cli.qmd#plans)): ```bash tether commit -m "Relabel plate1" --dry-run ``` ``` plan: commit (3 write(s)) pin zarr/imaging [icechunk] tether.0a1b2c3d.deb4861eb388e5f6 native ref at snapshot_id=K3F9M2A1B7C8D4E5… record features [lance] pin=record: state branch=main version=7, no native ref record raw/plate1 [file] Observed: recorded, not recoverable vcs-commit jj commit: 'Relabel plate1' db/metrics: unchanged ``` Nothing happened: no tag, no manifest write, no VCS commit. `--json` prints the same plan as data, and `--plan FILE` saves it for later: ```bash tether commit -m "Relabel plate1" --plan /tmp/commit.json # plan only # review, get sign-off, ... tether commit --from-plan /tmp/commit.json # apply it ``` Apply checks the world again first. If an object moved, or the manifests changed, the apply is refused with `StalePlanError` and nothing is written: plan again and look. `gc` and `drop` are dry runs by default, and need `--no-dry-run` (or `--from-plan`) to apply. From Python the split is explicit (`repo.plan_commit(...)` then `repo.apply_commit(plan)`, and so on), and `Plan.to_json()` and `Plan.from_json()` round-trip. The convenience forms (`tether commit`, `Repo.new()`, ...) plan and apply under one lock, with the same checks. ### What a plan checks before it applies A plan records what it saw as typed `preconditions` (`Plan.preconditions`, visible in a saved plan's JSON). Every `apply_*` runs them all before its first action, and the first mismatch stops the whole apply with `StalePlanError`. So a stale plan does nothing at all. A saved plan is valid only for the tether version that made it: it records that version (`tether_version`), and any other tether refuses to load it ("this plan was made by tether X; this is tether Y: re-run the plan"), as it does every plan saved before plans recorded one ("made by an older tether"). After upgrading `tether-vcs`, plan again. Each command *requires* some kinds, so a plan that lacks one is refused as stale ("lacks the ... precondition(s) tether requires of it"). A saved plan also carries a `digest` over its version, actions, context and preconditions. An edited plan fails the digest, so a dropped action or a changed target cannot apply under checks that describe another plan. The digest detects edits; it does not authenticate, since whoever can write the file can recompute it. | Command | Required preconditions | Per object | | --- | --- | --- | | `commit` | `workspace_id`, `workspace_bookmark`, `manifest_hash` | -- | | `new` | `workspace_id`, `manifest_hash` | each `fork`: `ref_head` (the branch exists) or `ref_absent`; each `reuse`: `ref_head`; each `adopt`: `ref_present` | | `restore` | `workspace_id`, `workspace_bookmark`, `manifest_hash` | each `fork`: `ref_head` or `ref_absent` | | `promote` | `workspace_id`, `workspace_bookmark`, `bookmark_head`, `manifest_hash` | each `fast-forward` or `merge`: `base_state`, and `ref_head` or `pin_state` | | `drop` | `workspace_id`, `workspace_bookmark`, `bookmark_head`, `no_new_holders`, `vcs_head`, `history_digest` | -- | | `gc`, `repair` | `workspace_id`, `history_digest` | -- | `workspace_id` binds a plan to the checkout that made it (`--plan` first persists the id in `workspace.toml`). `workspace_bookmark` binds it to the bookmark, as both the workspace file and the VCS see it: git's `HEAD` branch, or under jj the bookmarks at `@` (at `@-` when `@` has edits). A `bookmark_head` that names no bookmark fails. Three plans bind to no checkout: `import` checks the manifest hash, `upgrade` the dataset's version, and `forget-workspace` nothing. `history_digest` covers *every* visible commit, because a bookmark committed in another workspace may reference a pin a gc plan would release. A promote's `base_state` is the base branch's head, and `ref_head` or `pin_state` the state its source names. An `adopt` moves nothing, so it is not bound to a head. `ref_present` holds while the adopted branch exists and no newer generation of it (`.2`, `.3`) has appeared. When the store could say that a `--shared` adopt builds on the bookmark's pin, the plan also carries `ref_descends`: it holds while the branch still does, and fails if the store can no longer tell. A race *during* apply is handled per action instead: - The heads a plan reviewed are what the stores move *from*: a ref that moved in between is refused with `RefMovedError`, atomically where the backend can ([conditional moves](07-cli.qmd#conditional-moves)). - A branch that moves after gc's preflight is kept and reported, while the rest of the plan finishes. - `commit` fingerprints each object again against one snapshot. - `promote` lands the *state* the plan reviewed, not the source branch's head at apply time, so a write that slips in between is not promoted. - A lazy fork on the first writable `open` resets an existing branch only onto the head `new` reviewed and agreed to reset. Not every store write is a plan. `pull` pins what moved and commits it, `undo` deletes the branches an operation created, `abandon --gc` applies the `gc` plan it prints, `add --create` makes a store, and the first writable `open` forks the branch `new` decided on. None takes `--dry-run`, but `new --eager --dry-run` previews the forks, and `abandon` without `--gc` shows its plan without applying it. ## Lever 1: do not pin what you will not need -- `--pin record` Forking needs a *source*, not a *pin*. Say you branch off to try something, and only the two heads matter afterwards, not recovering the exact split point next year. Then the tag at the split point is pure cost. Register the object with `--pin record`: ```bash tether add features s3://bucket/features.lance --kind lance --pin record tether add zarr/imaging s3://bucket/imaging --kind icechunk --pin record ``` With `pin = "record"` on any backend: - `commit` records the state (a snapshot id, version, commit hash or LSN) but creates **no native ref**. The plan shows `record ... pin=record`. - `new` forks **straight from the recorded state**: the snapshot id in Icechunk and Iceberg, `(branch, version)` in Lance, the commit in Dolt and git, and in Neon the LSN on the branch it was read from. It does so at once even when forks are lazy, since that branch is what keeps the state alive, unless you pass `--defer-record` ([Lazy or eager](04-branching-and-writing.qmd#lazy-or-eager)). - `verify` checks that the state is still reachable, and `open -r` works while it is. The trade: recoverability is now bounded by the system's own retention. Icechunk's snapshot lives until someone expires it, Iceberg's until snapshot expiry runs, and Neon's until the history window closes. `verify --all-history` turns each expiry into a `MISSING` report, so you know which old commits have become labels without data. Mix per object: keep `--pin native` on the archive-quality store and use `record` on scratch. ## Lever 2: drop history you no longer want Say an experiment produced five commits and only the last matters. Each of the four intermediate commits holds pins. The one-step way is `tether abandon`. It drops the commits in the VCS and shows the `gc` plan for what that freed: ```bash tether abandon krlwtmzy ptnwqsyu # jj change ids, or git shas ``` ``` abandoned 3c1e9f0a2b7d, 8d2f4a1b9c0e plan: gc (2 write(s)) unpin zarr/imaging [icechunk] tether.0a1b2c3d.7d1e0c9a2b34e5f6 no manifest in history references it unpin db/metrics [neon] tether.0a1b2c3d.b81f4c2d9e60a7c3 no manifest in history references it (not applied; `tether gc --no-dry-run` releases these, or re-run with --gc) ``` Later commits keep their manifests exactly as they were: a manifest records a whole state, so removing an earlier one must not conflict with it, as a plain `jj abandon` or `git rebase` would. The abandon is the VCS's to undo (`jj undo`, the reflog); bring the commits back before running `gc`, and nothing was lost. The VCS's own tools work too, followed by `tether gc`: ::: {.panel-tabset} ### jj ```bash jj log -r 'main..@' # ○ vqmxzkwl Scores for A1, third try # ○ ptnwqsyu Scores for A1, second try # ○ krlwtmzy Scores for A1, first try # ○ ntsyulyo Relabel plate1 with model v3 # ◆ main # Keep the split point and the result; fold the tries into the result. jj squash --from krlwtmzy::ptnwqsyu --into vqmxzkwl ``` `jj squash` and `jj abandon` hide the old commits from the history `gc` walks. `jj op log` can restore them for a while, but after a `gc` a restored commit points at pins that are gone, and `verify` says so. ### git ```bash git log --oneline main..HEAD # 9e1c3a0 Scores for A1, third try # 5b77d21 Scores for A1, second try # c0ffee1 Scores for A1, first try # a1b2c3d Relabel plate1 with model v3 git rebase -i a1b2c3d # mark the two middle commits `fixup`/`drop` ``` `gc` walks `rev-list --all`, so anything unreachable from a ref is gone as far as tether is concerned. git's own reflog and `git gc` are independent of that. ::: To drop a whole side branch, `tether drop NAME` does the VCS half and the store half in one step ([Throwing it away](04-branching-and-writing.qmd#throwing-it-away-tether-drop)). History is now three commits, and the pins that only the dropped commits named are orphans. ## Lever 3: `gc` ```bash tether verify --all-history --deep # confirm what is still referenced actually resolves tether gc # dry run: the plan ``` ``` plan: gc (5 write(s)) unpin zarr/imaging [icechunk] tether.0a1b2c3d.7d1e0c9a2b34e5f6 no manifest in history references it unpin zarr/imaging [icechunk] tether.0a1b2c3d.c48b2f6e1d07a9b0 no manifest in history references it unpin db/metrics [neon] tether.0a1b2c3d.b81f4c2d9e60a7c3 no manifest in history references it unpin db/metrics [neon] tether.0a1b2c3d.3e5a9d07c1f28b64 no manifest in history references it delete-listing a1c9…e3f2.jsonl unreferenced ``` Two tags in Icechunk, two branches in Neon, one content listing. A plain `gc` does three things. It releases the pins nothing references, forgets the working refs of objects `tether remove` took away (their branches stay), and deletes listings no manifest names. It never deletes a branch. A pin counts as referenced when any of these names it, under any spelling of its store: - a manifest anywhere in VCS history, every side of a conflicted jj commit included; - the working tree of this or any other live checkout of the repository; - an operation still running, or interrupted, in any of them. Every ref carries the dataset's id (`0a1b2c3d` here), and `gc` only looks inside its own namespace. Had another dataset pinned the same Icechunk repository, the plan would say `zarr/imaging: 2 pin(s) of other datasets left alone`. Manifests of a backend tether no longer ships (`lakefs`) are skipped with a note, and their pins still count as references. Apply it: ```bash tether gc --no-dry-run ``` ``` unpinned 4 pin(s) icechunk: 0a1b2c3d.7d1e0c9a2b34e5f6 icechunk: 0a1b2c3d.c48b2f6e1d07a9b0 neon: 0a1b2c3d.b81f4c2d9e60a7c3 neon: 0a1b2c3d.3e5a9d07c1f28b64 deleted 1 orphan listing(s) ``` Or save the plan, and apply it from a job with the right credentials: ```bash tether gc --plan /tmp/gc.json tether gc --from-plan /tmp/gc.json ``` When some actions fail, `gc` prints what it applied along with the failures, and exits 3 (1 when nothing was applied). `tether undo` of a `gc` restores the working refs it forgot and the listings it deleted. Released pins and deleted branches stay released. If you restore a commit that references a released pin (`jj undo` of the abandon), `tether repair` recreates the pin from the manifest's state, while the system still has that snapshot. That window is the argument for running `gc` some time *after* a history edit. Releasing a tag or branch makes the data *eligible* for reclamation; the system decides when. Icechunk needs `repo.expire_snapshots(...)` and then `repo.garbage_collect(...)`, Iceberg `expire_snapshots`, and Lance `cleanup_old_versions`. Neon frees a branch's history on its own schedule. Those are the system's operations, not tether's. `gc` (and `drop`) refuse while jj reports a conflicted bookmark, or a conflict in `.tether/` that no later commit resolved. A conflicted bookmark has several targets and counts as reaching nothing, and a conflicted manifest is several answers. Resolve it (`jj bookmark set`, `jj resolve`) first. A conflict elsewhere, or one a later commit resolved, is history like any other. ### Pins this clone did not create `gc` only knows the history this clone has. A teammate's clone commits into the same stores, and until you fetch, its pins are in your store and in none of your commits. So an unreferenced pin is released only when **this clone created it**. Any other gets a `keep-pin` verdict: ``` keep-pin db/metrics [neon] tether.0a1b2c3d.5e6f7a8b9c0d1e2f no manifest in history references it; not created by this clone -- another clone's commits may name it (fetch them, or --release-foreign) ``` `keep-pin` writes nothing, and a plan holding only such lines is empty. Fetching the other clone's bookmarks turns its pins into references. `gc --release-foreign` releases them regardless, for a clone you know is gone or a pin made by hand; the plan marks each one `not created by this clone`. Which pins are this clone's is recorded in `tether-pinned.jsonl`, beside the repository lock (`.jj/repo/` or `.git/`): - `commit`, `pull` and `repair` add a pin when the store reports the ref as new. A commit that found the pin already there (another clone made it) does not claim it. - A pin this clone releases is taken off, so another clone creating it again under the same content-addressed id owns it. - The first `gc` seeds the index from every live checkout's op log, from the progress records of the pins each operation created. - The index is not in history. A fresh clone, including a re-clone of your own dataset, starts without it, and its `gc` keeps every unreferenced pin as `keep-pin`. ### Branches of bookmarks that are gone Every bookmark gets a `tether.ws..` branch in each Forkable system it writes to. A bookmark deleted with `jj bookmark delete`, renamed, or left on a wiped machine leaves those branches behind, each holding whatever it wrote. A plain `gc` never deletes a branch, so a forgotten branch is never why data disappears. `gc --prune-bookmarks` is the sweep. It finds the `tether.ws.*` branches in each system and keeps those of every bookmark the VCS still has, or a live checkout works on. It also keeps any `--keep-bookmark NAME` (a bookmark that exists only on another machine), and every branch a live checkout has as its working ref or pending fork. It judges the rest by their head: gone bookmarks' branches, this bookmark's branches that no current object uses, and legacy per-workspace branches from before bookmarks. This table is the one place the verdicts are defined; `drop` adds [one rule of its own](04-branching-and-writing.qmd#throwing-it-away-tether-drop): | Branch head | Default | `--force-prune` | | --- | --- | --- | | natively pinned by some commit (a tag holds it) | `delete-branch` | `delete-branch` | | equal to the base branch's head (nothing was ever written) | `delete-branch` | `delete-branch` | | has writes no commit pinned | `keep-branch` | `delete-branch FORCED` (data lost) | | a `--pin record` state (only this branch keeps it reachable) | `keep-branch` | `delete-branch FORCED` | | on a backend whose branches *are* the storage (`BRANCH_IS_STORAGE`: Neon) | `keep-branch` | `delete-branch FORCED` | | the store refuses to delete it (Neon: pins hang off it) | `keep-branch: cannot be deleted` | the same | Both sweeps stay inside this dataset's namespace. Pins and branches whose name carries another dataset's id are counted in the plan's notes (`N pin(s) of other datasets left alone`) and never touched, `--force-prune` included. ```bash jj bookmark delete relabel # the line of work is over tether gc --prune-bookmarks # its branches, everywhere tether gc --prune-bookmarks --keep-bookmark hpc # a bookmark that lives only on the cluster ``` ``` plan: gc (1 write(s)) delete-branch zarr/imaging [icechunk] tether.ws.0a1b2c3d.relabel bookmark relabel (gone); head is pinned keep-branch features [lance] tether.ws.0a1b2c3d.relabel bookmark relabel (gone); kept: has unpinned writes (branch=tether.ws.… version=9) (--force-prune deletes) keep-branch db/metrics [neon] tether.ws.0a1b2c3d.relabel bookmark relabel (gone); kept: branch is storage; deleting reclaims its data (--force-prune deletes) keeping live bookmarks: hpc, main ``` Add `--no-dry-run` to apply. The kept branches are yours to decide. Pin what is on them (`tether new -b NAME` on a commit, then `commit`), or decide the data is disposable and pass `--force-prune`. The plan marks those `FORCED although it has unpinned writes`, so the loss is explicit first: ```bash tether gc --prune-bookmarks --force-prune --no-dry-run ``` `tether forget-workspace [ID]` retires a *checkout* and touches no branch: branches belong to bookmarks, which stay live for `gc` while the VCS has them. `tether remove` deletes nothing either; `--prune-bookmarks` later judges the object's branch. (Lance refuses to delete a branch a tag still points at; tether reports it and moves on.) ### Stores tether created ::: {.callout-warning title="Experimental"} Everything in this section (`add --create`, `Repo.create`, `gc --delete-stores`, `--store`) lives in `tether.experimental.lifecycle`. It is the one operation with no `repair` path, and has not yet run against an S3 prefix or a second clone with a real remote. The rules err toward keeping. The module's docstring lists what must hold before it graduates. ::: Everything above releases *refs* inside stores that existed before tether saw them; tether never deletes such a store. A store made with `tether add KEY LOCATOR --kind KIND --create` is different (`repo.add(..., create=True)`; `repo.create(key, kind, locator)` also opens it). tether made it, so tether may take it away once it is plainly garbage: an ephemeral environment creates what it needs, tests, and throws it all away. Two records outside history say what tether made: - **An owner marker in the store**, naming the dataset: Icechunk repository metadata, `git config tether.owner`, or a `.tether-owner` file. Manifests are untrusted input, so only the marker authorizes a delete. - **`tether-created.jsonl`**, beside the repository lock and never committed, since an abandoned fork takes with it the manifest that named the store. `gc --delete-stores` considers these stores last. It is opt-in, because a deleted store has no `repair` and `gc` only knows the history this clone has fetched. A store is planned for `delete-store` only when all five hold: 1. Its owner marker names this dataset. A marker naming another dataset is noted and left alone; a store with no marker is only dropped from the index (`forget-store`). 2. No manifest in visible history, in this working tree, or in any live checkout's working tree names it. 3. Every ref of *this dataset* in it is one this clone can account for. A branch must belong to the bookmark the store was created on, to a live bookmark, or to one a `new` in a live checkout of this clone made. A pin must be one that a `commit` or `repair` of this clone recorded. Anything else may be another clone at work in the store, whose commits nothing in your history protects. The plan then touches *nothing* in that store, your own pins included (`keep-store`), and says so. 4. Everything of tether's in it is released by the same plan: its pins are unpinned, and its branches deleted under the `--prune-bookmarks` rules. Without `--prune-bookmarks` a branch blocks the store; with unpinned writes it needs `--force-prune`. 5. The backend confirms nothing else remains: the base branch at its initial state, the marker, the refs the plan deletes, and no uncommitted files in a store with a working area (a git checkout). "Cannot tell" means keep. Otherwise the plan carries a `keep-store` naming what is in the way. `delete-store` runs last, behind a `store_empty` precondition asked again with nothing ignored, and the backend checks owner and emptiness once more itself. A tag or branch that appeared in between stales the plan. `undo gc` reports a deleted store as irreversible. ```bash tether gc --prune-bookmarks --delete-stores # shows delete-store / keep-store tether gc --prune-bookmarks --delete-stores --no-dry-run # ...and applies it tether gc --prune-bookmarks --delete-stores --force-prune # ...overriding rules 3 and 4 ``` Fetch every bookmark before you run it: a bookmark another clone pushed is a reference `gc` can only count once it is in your history. Two more indexes and one escape hatch round this out: - **Stores this clone touched.** Every store a checkout forks a branch in or pins is recorded in `tether-touched.jsonl`. `gc --delete-stores` releases this dataset's dead refs there even when no manifest names the store any more, as an abandoned bookmark leaves it. So when another actor abandons refs in *your* created store, *their* next `gc --delete-stores` cleans them out (rule 3 binds them too), and yours then finishes. An entry goes (`forget-touched`) once nothing of the dataset's is left. - **A store whose creator is gone.** Any clone can reclaim a created store by naming it: `gc --delete-stores --store KIND=LOCATOR` (repeatable; `LOCATOR` is the `uri`). The same five rules apply, with the marker as the authority, and a store with no marker is refused. ```bash tether gc --prune-bookmarks --store icechunk=s3://bucket/probes/emb.icechunk --no-dry-run ``` Two consequences. A clone elsewhere has its own (empty) index, so it reclaims the stores *you* created only when named by locator. And a store created on the trunk and written through its base branch is kept forever by rule 5: create on a bookmark when you mean to throw the store away. ## Recap - Plans: `--dry-run` shows store writes, `--plan FILE` saves them, and `--from-plan FILE` applies them and refuses if anything moved. - `--pin record` skips the native ref and forks from the recorded state: cheaper, and bound by retention. - Drop commits with `tether abandon`, `tether drop`, or the VCS's own tools; what the VCS cannot see, tether does not protect. - `gc` releases orphaned pins this clone created, and never deletes a branch on its own. `--prune-bookmarks` deletes gone bookmarks' branches when nothing on them would be lost, and `--force-prune` regardless. - `gc --delete-stores` deletes a store `add --create` made once nothing references it; the owner marker decides, never a manifest. ### Worked Examples Seven situations tether is built for, run end to end as one story. Each section starts where the previous one left the dataset, and ends with `jj log` and a pointer to the page that explains the mechanics. The dataset is an Icechunk store `zarr/imaging`, a Lance table `features`, a directory of raw files `raw/plate1`, and the analysis code repository `code`. (The Neon database of the other pages is left out, so the story runs locally.) | Section | Situation | | --- | --- | | [1](#reproduce-an-analysis-months-later) | Reproduce an analysis months later | | [2](#reprocess-on-a-branch-then-land-it-or-throw-it-away) | Reprocess on a branch, then land it or throw it away | | [3](#ab-two-candidates-keep-one) | A/B: two candidates, keep one | | [4](#catch-drift-nightly-without-a-watcher) | Catch drift nightly without a watcher | | [5](#keep-the-data-bill-down) | Keep the data bill down | | [6](#recover) | Recover from mistakes | | [7](#publish-history-to-a-registry-experimental) | Publish history to a registry (experimental) | ::: {.callout-note title="Executed, not written"} `tests/test_use_cases.py` runs every command on this page against local copies of the objects (and an ephemeral Postgres for section 7). It writes the command and output blocks you see (`user_guide/_generated/06/`), and CI fails when a fresh run differs. Ids that change per run are replaced with stable stand-ins, so the same id means the same thing everywhere on this page, and the operation log runs on the story's clock. ::: `jj log` uses a compact template set in the examples' jj config: change id, commit id, bookmarks, other workspaces' working copies, `(empty)`, and the description. `@` is this checkout's working copy, `◆` the root. Under git, read a bookmark as a branch and `@` as `HEAD`. ## 0. Set up {{< include _generated/06/01-init.sh >}} {{< include _generated/06/01-init.txt >}} {{< include _generated/06/02-init.sh >}} {{< include _generated/06/02-init.txt >}} {{< include _generated/06/03-add.sh >}} {{< include _generated/06/03-add.txt >}} Nothing has been contacted yet. The VCS sees a few small TOML files (the config and one manifest per object), and a `main` bookmark `init` put on the working copy: the trunk, which stands for every object's own `main`. {{< include _generated/06/04-status.sh >}} {{< include _generated/06/04-status.txt >}} `.tether/workspace.toml` and `.tether/ops.jsonl` are not listed: they are per-checkout state, ignored by the `.gitignore` that `init` wrote. ## 1. Reproduce an analysis months later *The report is out. Someone asks how a number was computed. The code is in git; the data it read has moved on three times since.* Commit the inputs next to the code that read them: {{< include _generated/06/05-commit.sh >}} {{< include _generated/06/05-commit.txt >}} That commit holds the manifests -- one file per object naming the pin -- and sits in the same history as `make_report.py`. Months later, from any clone, the commit id is enough: {{< include _generated/06/06-open-rev.sh >}} {{< include _generated/06/06-open-rev.txt >}} {{< include _generated/06/07-open-rev-py.py >}} `TETHER_REV=695fe286e11b python make_report.py` does the same for a whole script: every `repo.open()` in it resolves at that commit's pins. And in CI, so you find out the day a pin stops resolving rather than the day you need it: {{< include _generated/06/08-verify.sh >}} {{< include _generated/06/08-verify.txt >}} (Under jj, `--all-history` includes the empty working-copy commit on top of the one you just made; it holds the same manifests, so it verifies the same pins.) What tether guaranteed: the Icechunk, Lance and git tags are native refs their systems will not expire or collect. The raw prefix was *recorded* (size, count, etag digest), and `verify` says whether it still matches, but files cannot be pinned; the commit said so. This commit was made on the trunk bookmark, `main`, where each object's working ref is its own `main`: the inputs are whatever those branches held. The commit moved the bookmark with it, and jj opened a fresh empty working copy on top, as `jj commit` would: {{< include _generated/06/09-log.sh >}} {{< include _generated/06/09-log.txt >}} Mechanics: [Pinning](03-pinning.qmd). ## 2. Reprocess on a branch, then land it or throw it away *A new model should relabel the imaging store and rewrite the features table. Production readers must not see a half-done state, and the attempt may fail.* {{< include _generated/06/10-new-relabel.sh >}} {{< include _generated/06/10-new-relabel.txt >}} A `relabel-v3` bookmark now sits where `main` is, and every system will get a branch of the same name. Nothing was created yet: the job opens what it writes, and the branch for that object appears in its system at that moment, forked from the pin: {{< include _generated/06/11-relabel-job.py >}} `main` -- the bookmark and every system's branch -- is untouched throughout. When the job is done, commit pins the branches and moves `relabel-v3`. Only the two objects the job wrote get new pins; `code` and `raw/plate1` are as the last commit left them: {{< include _generated/06/12-commit-relabel.sh >}} {{< include _generated/06/12-commit-relabel.txt >}} {{< include _generated/06/13-diff-relabel.sh >}} {{< include _generated/06/13-diff-relabel.txt >}} {{< include _generated/06/14-log-relabel.sh >}} {{< include _generated/06/14-log-relabel.txt >}} The bookmark moved; `main` did not. Two endings. Land it: {{< include _generated/06/15-promote-relabel.sh >}} {{< include _generated/06/15-promote-relabel.txt >}} Nothing moved. Lance refuses, since it cannot move one branch onto another, and says what to do instead: write the result onto the base branch. Icechunk *could* have fast-forwarded, and `promote` says so, but holds it: new labels beside old features on `main` is the half-done state this exercise exists to avoid ([planned whole or not at all](04-branching-and-writing.qmd#landing-it-tether-promote)). The two objects the job never opened are skipped. Landing it by hand is then two steps. Name the system that can move, the explicit way to land a subset (the trunk bookmark does not move for one): {{< include _generated/06/16-promote-imaging.sh >}} {{< include _generated/06/16-promote-imaging.txt >}} Then finish the Lance half on the trunk and commit there. Pins are content-addressed, so the imaging store, now at what `relabel-v3` pinned, gets the very same pin id, and only `features` gets a new one: {{< include _generated/06/17-new-main.sh >}} {{< include _generated/06/17-new-main.txt >}} {{< include _generated/06/18-finish-features.py >}} {{< include _generated/06/19-commit-features.sh >}} {{< include _generated/06/19-commit-features.txt >}} {{< include _generated/06/20-log-landed.sh >}} {{< include _generated/06/20-log-landed.txt >}} Two lines of history describe the same data now, and the bookmark has done its job. `drop` throws it away. It is a plan first: the commits only this bookmark reaches, the bookmark, and what `gc` will release once they are gone: {{< include _generated/06/21-drop-relabel-plan.sh >}} {{< include _generated/06/21-drop-relabel-plan.txt >}} {{< include _generated/06/22-drop-relabel.sh >}} {{< include _generated/06/22-drop-relabel.txt >}} The one pin nothing else names, the Lance version on the branch, is released (the imaging pin is also `main`'s). Both branches go: Icechunk's head is `main`'s pin, and Lance's head is a state the dropped commit pinned. That last rule is [`drop`'s own](04-branching-and-writing.qmd#throwing-it-away-tether-drop): `gc --prune-bookmarks` would keep the branch as "unpinned writes". {{< include _generated/06/23-log-clean.sh >}} {{< include _generated/06/23-log-clean.txt >}} The other ending, a failed attempt, is the same `drop` without the trunk commit in between. Run from the bookmark itself, it leaves for the trunk first (`--to` picks another bookmark). `drop` is not undoable by tether, but [three steps](04-branching-and-writing.qmd#throwing-it-away-tether-drop) bring a bookmark back. ::: {.callout-note title="Rehearsing a database migration"} The same flow rehearses a schema change on a Neon database. Run `tether new -b migration`, apply the migration at the Neon branch's URL, run the tests, and watch the row counts move with `tether diff main --content`. Neon cannot promote a child branch, so landing it is the trunk recipe above, or the migration applied to `main` itself: the branch was the rehearsal. ::: Mechanics: [Branching and Writing](04-branching-and-writing.qmd#landing-it-tether-promote). ## 3. A/B: two candidates, keep one *Two labeling approaches. Run both against the same inputs, compare, promote the winner, and leave nothing of the loser behind.* Each candidate is a bookmark, `a` and `b`, and each bookmark is a branch of the same name in every system, so the two never share a branch. Run them side by side from two checkouts (a jj workspace or git worktree each): **A** works in `analysis/`, **B** in `analysis-b/`. **Set up.** B's checkout is created from A's; each starts its own bookmark. From A: {{< include _generated/06/24-workspace-add.sh >}} {{< include _generated/06/24-workspace-add.txt >}} {{< include _generated/06/25-new-a.sh >}} {{< include _generated/06/25-new-a.txt >}} And in B: {{< include _generated/06/26-new-b.sh >}} {{< include _generated/06/26-new-b.txt >}} **Run and commit.** Each job labels the imaging store through its own handle. Each `commit` pins its bookmark's branch, lands a dataset commit, and moves the bookmark onto it. A: {{< include _generated/06/27-label-a.py >}} {{< include _generated/06/28-commit-a.sh >}} {{< include _generated/06/28-commit-a.txt >}} B: {{< include _generated/06/29-label-b.py >}} {{< include _generated/06/30-commit-b.sh >}} {{< include _generated/06/30-commit-b.txt >}} Two bookmarks, two lines of history, one fork point -- and the same shape in the Icechunk store, where `tether.ws.0a1b2c3d.a` and `tether.ws.0a1b2c3d.b` both branch off the snapshot `main` pins. From A, B's working copy shows as `analysis-b@`: {{< include _generated/06/31-log-ab.sh >}} {{< include _generated/06/31-log-ab.txt >}} **Compare**, from either checkout, as datasets rather than directories. Icechunk cannot diff one branch head into another directly, so tether diffs each against the snapshot they diverged from: {{< include _generated/06/32-diff-ab.sh >}} {{< include _generated/06/32-diff-ab.txt >}} **Land A.** One command from A. `promote` fast-forwards Icechunk's `main` onto `a`'s branch and skips the objects A never opened. Everything having landed, it moves the `main` bookmark onto A's dataset commit. {{< include _generated/06/33-promote-a.sh >}} {{< include _generated/06/33-promote-a.txt >}} B is untouched: its bookmark and branches are its own. If B's checkout later moves onto the new `main` (`jj new main`), `tether status` says the working copy left bookmark `b`, and `tether new b` returns to it. **Retire B** -- from A's checkout, since B's is about to stop being one. Forget B's checkout first: `drop` refuses a bookmark another live checkout is working on, and that is the guard you want. Then drop the bookmark: {{< include _generated/06/34-forget-b.sh >}} {{< include _generated/06/34-forget-b.txt >}} {{< include _generated/06/35-drop-b.sh >}} {{< include _generated/06/35-drop-b.txt >}} Nothing is left to run in `analysis-b/`, and the directory can be deleted (`git worktree remove` already did, for git). B's Icechunk branch went with the commit that pinned its head, by `drop`'s own rule. {{< include _generated/06/36-log-final.sh >}} {{< include _generated/06/36-log-final.txt >}} B is gone from history, its pin from the store, and its branch from Icechunk. `a` and `main` point at the same commit; `jj bookmark delete a` and a later `gc --prune-bookmarks` retire the winner's branches the same way once nobody works on them. Mechanics: [Workspaces and bookmarks](04-branching-and-writing.qmd#workspaces-and-bookmarks), [`drop`](04-branching-and-writing.qmd#throwing-it-away-tether-drop). ## 4. Catch drift nightly without a watcher *More inputs across four systems. Some are "immutable" raw files. Something will change under you eventually, and you would rather hear it from a cron job than from a wrong result.* Back on the trunk, register the rest of the inventory -- by hand, or from the registry or spreadsheet that already lists it -- and commit it: {{< include _generated/06/37-new-main-4.sh >}} {{< include _generated/06/37-new-main-4.txt >}} {{< include _generated/06/38-import.sh >}} {{< include _generated/06/38-import.txt >}} {{< include _generated/06/39-commit-inventory.sh >}} {{< include _generated/06/39-commit-inventory.txt >}} {{< include _generated/06/40-log-inventory.sh >}} {{< include _generated/06/40-log-inventory.txt >}} Then schedule two commands. `status --snapshot` fingerprints every object concurrently and only *reports*. On the trunk, `modified` means an upstream branch moved, and `behind` that a branch-less object's system is past the pin; both exit 0. `verify` checks that every pin still names what the manifest says. Each exits 1 when something is wrong: an unreachable object, a changed immutable file, a pin that does not resolve. On a quiet night: {{< include _generated/06/41-nightly.sh >}} {{< include _generated/06/41-nightly.txt >}} **A raw file overwritten in place.** The instrument re-exports one well of a plate registered as `file = "immutable"` (the default). It is the *bytes* that count, not the timestamp; `status` labels the plate `error`, reports the rest of the dataset as usual, and exits 1: {{< include _generated/06/42-overwrite.sh >}} {{< include _generated/06/43-status-immutable.sh >}} {{< include _generated/06/43-status-immutable.txt >}} That is deliberate: an "immutable" input silently changing is the worst kind of drift. Either something wrote where it should not have, or -- as here -- the change is legitimate, and accepting it is a re-registration, not a pull: {{< include _generated/06/44-accept.sh >}} {{< include _generated/06/44-accept.txt >}} **Pins deleted by hand.** Someone "tidies up" the tags in two stores: {{< include _generated/06/45-delete-tags.py >}} {{< include _generated/06/46-verify-missing.sh >}} {{< include _generated/06/46-verify-missing.txt >}} `repair` recreates every missing pin from its recorded state, while the store still has that state: {{< include _generated/06/47-repair.sh >}} {{< include _generated/06/47-repair.txt >}} Lance gets its tag back under the same name. Icechunk never lets a deleted tag's name be reused, so tether creates the pin under the next *generation* of the same id, `tether..2`. The pin id and the manifest stay, and readers resolve a pin through its live generation, so history needs no rewriting. `verify` says which one: {{< include _generated/06/48-verify-repaired.sh >}} {{< include _generated/06/48-verify-repaired.txt >}} {{< include _generated/06/49-open-after-repair.sh >}} {{< include _generated/06/49-open-after-repair.txt >}} (Between the deletion and the `repair`, reads and forks at that commit worked too, by the snapshot id the manifest records: a deleted tag does not make the data unreachable while the store keeps the snapshot.) **Upstream moves on its own.** A pipeline appends to the Lance table; a colleague commits to the code repository. On the trunk those branches *are* the working refs, so `status` sees them as `modified`: {{< include _generated/06/50-upstream-moves.py >}} {{< include _generated/06/51-code-commit.sh >}} {{< include _generated/06/52-status-moved.sh >}} {{< include _generated/06/52-status-moved.txt >}} Nothing watches anything. Each run fingerprints the objects and compares them with the manifests, one round of metadata calls. Neither command touches history. To have the dataset *trail* the world rather than watch it, a nightly `tether pull` on `main` commits whatever moved: {{< include _generated/06/53-pull.sh >}} {{< include _generated/06/53-pull.txt >}} {{< include _generated/06/54-log-pull.sh >}} {{< include _generated/06/54-log-pull.txt >}} Mechanics: [Drift](03-pinning.qmd#drift) and [Verify](03-pinning.qmd#verify); `import` is in [Registries and SQL](10-registries-and-sql.qmd#import). ## 5. Keep the data bill down *Two weeks of experiments, five commits a day, each pinning a snapshot the store must now keep. Only the last one matters.* Decide at `add` time which objects deserve durable pins. The sweep's scratch table gets `--pin record`: its state is recorded in every commit, but no tag is created, so the table's own retention decides how long each version lives. Commit the registration, then run the sweep on its own bookmark: {{< include _generated/06/55-add-scratch.sh >}} {{< include _generated/06/55-add-scratch.txt >}} {{< include _generated/06/56-new-sweep.sh >}} {{< include _generated/06/56-new-sweep.txt >}} (A `record` object gets its branch at `new` time, while the recorded version is known to exist.) Each trial relabels the imaging store and appends to the scratch table, in `sweep.py`: {{< include _generated/06/57-sweep-py.py >}} {{< include _generated/06/58-trials.sh >}} {{< include _generated/06/58-trials.txt >}} {{< include _generated/06/59-log-sweep.sh >}} {{< include _generated/06/59-log-sweep.txt >}} Four Icechunk tags the store must now keep. When the experiment is over, drop the commits that were only tries and release what only they pinned, in one step. Later commits keep their manifests exactly as they were (they are snapshots, not patches), so trial 4 still names its own pins: {{< include _generated/06/60-abandon-trials.sh >}} {{< include _generated/06/60-abandon-trials.txt >}} {{< include _generated/06/61-log-abandoned.sh >}} {{< include _generated/06/61-log-abandoned.txt >}} Trial 4 was rebased onto trial 1 (new commit id, same change id, same manifests), and the `sweep` bookmark followed it. `scratch/embeddings` freed nothing: it had no pins, which was the point of `--pin record`. (`abandon` restores each descendant's manifests, so the empty working copy is created again on top, with a new change id.) Releasing a tag makes the snapshot *eligible*; the store reclaims the bytes on its own schedule. Recorded states past their retention show up in `verify --all-history` as missing: the trade you made with `--pin record`, stated back to you. **A store that should not outlive its probe** *(experimental)*. So far every store existed before tether saw it, and tether never deletes what it merely adopted. A probe often needs a store of its own, such as a second embeddings table, with no reason to exist once the probe is thrown away. `add --create` has tether make it, on a bookmark of its own ([Stores tether created](05-reclaiming-storage.qmd#stores-tether-created)). {{< include _generated/06/62-ls-before.sh >}} {{< include _generated/06/62-ls-before.txt >}} {{< include _generated/06/63-new-probe.sh >}} {{< include _generated/06/63-new-probe.txt >}} {{< include _generated/06/64-add-probe.sh >}} {{< include _generated/06/64-add-probe.txt >}} The store is created empty, with an owner marker naming this dataset, and remembered with the bookmark it was made on in `.jj/repo/tether-created.jsonl`. On a bookmark its working branch is forked at once, so the first writable `open` lands on it. (`repo.create("scratch/probe", "icechunk", {"uri": ...})` does the `add` and the `open` in one call.) {{< include _generated/06/65-probe-py.py >}} {{< include _generated/06/66-commit-probe.sh >}} {{< include _generated/06/66-commit-probe.txt >}} {{< include _generated/06/67-status-probe.sh >}} {{< include _generated/06/67-status-probe.txt >}} {{< include _generated/06/68-log-probe.sh >}} {{< include _generated/06/68-log-probe.txt >}} The probe did not pan out. One `drop`, from the bookmark itself: `--to sweep` says where to leave for, and `--delete-stores` opts into the experimental step for the store the probe created: {{< include _generated/06/69-drop-probe.sh >}} {{< include _generated/06/69-drop-probe.txt >}} {{< include _generated/06/70-ls-after.sh >}} {{< include _generated/06/70-ls-after.txt >}} Leave, drop the commit, unpin, delete the branches (the sweep table's too: its recorded state was the dropped commit's), and, with nothing left but the owner marker, delete the store. The data directory is as it was before the probe. A store tether *adopted* is never deleted this way, and editing a manifest cannot make it happen: only the marker in the store and the index in your repository say a store is tether's to remove. Had another clone forked its own branch in the store meanwhile, the plan would have kept the store whole, and said so. Mechanics: [Reclaiming Storage](05-reclaiming-storage.qmd). ## 6. Recover *Something went wrong. Three kinds of wrong, and what comes back.* **An unwanted `new`** -- you started a bookmark you did not mean to, and `--eager` forked every branch at once. The op log knows what was created: {{< include _generated/06/71-new-scratch.sh >}} {{< include _generated/06/71-new-scratch.txt >}} {{< include _generated/06/72-ops.sh >}} {{< include _generated/06/72-ops.txt >}} {{< include _generated/06/73-undo-new.sh >}} {{< include _generated/06/73-undo-new.txt >}} Five branches deleted, the workspace file and the VCS working copy put back, the bookmark gone: the checkout is on `sweep` again as if nothing happened. `sweep`'s own branches were never in play, since a new bookmark forks its own. **One object's branch on the wrong state** -- everything else is fine, only `zarr/imaging` should go back to what an older commit pinned. `restore` resets the current bookmark's branch for that object, and nothing else: {{< include _generated/06/74-restore.sh >}} {{< include _generated/06/74-restore.txt >}} {{< include _generated/06/75-status-restored.sh >}} {{< include _generated/06/75-status-restored.txt >}} The other branches, the manifests and the working copy stay put, and the next `commit` would pin what you restored. Here it was a demonstration. `undo` does not guess where a branch it *reset* should go, so it restores the workspace record, says what the branch was, and leaves the move to you: {{< include _generated/06/76-undo-restore.sh >}} {{< include _generated/06/76-undo-restore.txt >}} `restore` is its own undo: name the commit whose state you want. The branch holds the demonstration's state, which this workspace never committed, so `restore` asks for `--discard` before it throws that away: {{< include _generated/06/77-restore-back.sh >}} {{< include _generated/06/77-restore-back.txt >}} **Several things in a row.** A restore you did not mean, then a bookmark you did not mean. `undo` reverses the newest operation each time; run it once per slip: {{< include _generated/06/78-restore-features.sh >}} {{< include _generated/06/78-restore-features.txt >}} {{< include _generated/06/79-new-again.sh >}} {{< include _generated/06/79-new-again.txt >}} {{< include _generated/06/80-ops-again.sh >}} {{< include _generated/06/80-ops-again.txt >}} {{< include _generated/06/81-undo-again.sh >}} {{< include _generated/06/81-undo-again.txt >}} {{< include _generated/06/82-undo-restore-features.sh >}} {{< include _generated/06/82-undo-restore-features.txt >}} The restore had *created* `features`' branch on `sweep` (nothing had written to `features` there yet), so undoing it deletes that branch again, and `sweep` has exactly the branches it had before. **A pin deleted by hand, or by a `gc` run on the wrong clone**: the manifests still promise it, and section 4 ran this one. `verify` reports `missing`, and `repair` recreates the pin from the recorded state; until then, reads and forks at that commit fall back to the recorded snapshot. None of this touched history: the `scratch` and `again` bookmarks came and went without a commit, and `restore` acts on the stores. The log is what section 5 left, and the workspace is back on `sweep`: {{< include _generated/06/83-log-recovered.sh >}} {{< include _generated/06/83-log-recovered.txt >}} tether's `undo` and `repair` cover tether's side, and say exactly what they could not do (a snapshot the store already expired, a `promote`); `jj undo` covers the VCS's ([Undo](04-branching-and-writing.qmd#undo)). Mechanics: [`ops`, `undo`, `repair`](07-cli.qmd#ops-undo-repair) in the CLI Guide. ## 7. Publish history to a registry (experimental) *A dashboard, a training pipeline, and a data catalog all want to know which state of which table a run used. None of them should have to clone the repository to find out.* Push the history into Postgres -- after every commit to `main`, typically from CI. Every commit on every bookmark goes, with its parents, refs, and one row per object per commit: {{< include _generated/06/84-publish.sh >}} {{< include _generated/06/84-publish.txt >}} Consumers join their own run log against `tether.objects` -- the per-commit table; `tether.object_states` is the distinct (system, state) pairs a catalog keys on. A training run recorded which dataset commit it read, and the join says what that meant for every input: {{< include _generated/06/85-consumer-join.py >}} {{< include _generated/06/85-consumer-join.txt >}} `pin_id` is null for the recorded-only objects (the raw plates, the `--pin record` scratch table); their `state_json` is still the exact state. For a provenance dashboard, `tether export` writes the same tables as files: {{< include _generated/06/86-export.sh >}} {{< include _generated/06/86-export.txt >}} When the registry, not the repository, is where objects are declared, run the other direction: `tether import` from a query, then `commit`. Section 4 did that from a CSV. Producers and consumers meet in the middle. The producer commits and publishes. The consumer's pipeline verifies the commit it is about to read, and refuses to start on a pin that is gone (`--deep` opens each recorded state, the only way to check a `record`-pinned object): {{< include _generated/06/87-verify-rev.sh >}} {{< include _generated/06/87-verify-rev.txt >}} `publish` and `export` do not write to the repository, so the story ends where section 5 left it: the sweep on its bookmark, `main` behind it. Landing it is section 2's recipe. `promote` will refuse the Lance scratch table and hold the imaging store, so `promote zarr/imaging` lands the labels on purpose, and the scratch rows stay on their branch. {{< include _generated/06/88-log-end.sh >}} {{< include _generated/06/88-log-end.txt >}} Mechanics: [Registries and SQL](10-registries-and-sql.qmd). ## Reference ### 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](03-pinning.qmd), [Branching and Writing](04-branching-and-writing.qmd) and [Reclaiming Storage](05-reclaiming-storage.qmd). ``` 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` 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`, printed to stderr as `error: ...`. Also `verify` when a report is not `ok`, `status` when an object is `error`, and `gc` or `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` and `forget-workspace` print each part they could not do as `IRREVERSIBLE` or `FAILED`. `gc` and `promote` print what they applied, then the failures, and add `failed` to their JSON. | `verify` counts `unknown` as not `ok`; a Delta, DuckLake or `--pin record` state reports it without `--deep`. `status` exits 0 for `modified` and `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`. Each error class and its way out: [Troubleshooting](14-troubleshooting.qmd). ## 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/` 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`, 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`, `snapshot`, `verify` and `diff` then contact and report only the objects named, with the same exit codes and JSON shapes: `status` exits 1 only for an `error` among them, and `snapshot` leaves the cached states of the rest as they were. `verify --rev` and `diff` select from the manifests they read, so a key only one side of a `diff` has can be named. `verify --all-history` and `repair --all-history` select from every key history has had. `restore`, `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` 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` hold only operators and commit ids. A repo-level alias of `if()`, of a lambda parameter or of `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](08-configuration.qmd#security-model)). Every environment variable tether reads, in one table: | Variable | Effect | | --- | --- | | `TETHER_REV` | Default `--rev` for `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](12-caveats-and-performance.qmd#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](09-backends.qmd). ## Plans Ten commands plan before they write: `commit`, `new`, `restore`, `promote`, `gc`, `drop`, `forget-workspace`, `import`, `repair` and `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`'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` 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` makes an edited plan fail. What each plan checks, and which bind to their checkout: [What a plan checks](05-reclaiming-storage.qmd#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`, `pull` | `pin`, `record`, `vcs-commit` | | `new` | `fork`, `defer-fork`, `reuse`, `adopt`, `share`, `trunk`, `refuse` | | `restore` | `fork`, `share`, `refuse` | | `promote` | `fast-forward`, `merge`, `share`, `hold`, `refuse` | | `gc` | `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`, `update`, `remove` | | `publish` | `upsert` | | `repair` | `repin`, `refork` | | `upgrade` | `rename-pin`, `rename-branch`, `rewrite-history`, `rewrite-manifest`, `refingerprint`, `rename-manifest`, `rewrite-locator`, `rewrite-index`, `copy-listing`, `rewrite-state`, `vcs-commit` | `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` 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`, `promote` and `merge` take the expected head (or "absent"), and raise `RefMovedError`, moving nothing, when the ref holds something else: | Backend | The check and the move | | --- | --- | | `git`, `memory` | One atomic step (`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`) 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](08-configuration.qmd#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](14-troubleshooting.qmd#recovering-a-lost-dataset-repo)). ## `add`, `set`, `remove` The positional `LOCATOR` becomes the `uri` field. Named options map to locator fields, and `--set key=value` (repeatable) covers the rest. Which fields each backend reads: [Backends](09-backends.qmd). `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` | file, icechunk, delta, lance, ducklake (`metadata`) | | `--branch` | `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` 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` | 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](05-reclaiming-storage.qmd#stores-tether-created). | `--file` and `--pin` set the [policy](08-configuration.qmd#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` ``` 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`), 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`, `modified` (the branch differs from the commit), `clean`, and `behind`: on the trunk, a branch-less or `--at` object whose system moved past its pin. `error` means the fingerprint failed, now or at the last snapshot; the reason goes to stderr and `status` exits 1. - **Marks.** `unrecoverable` (Observed), `(created)` (made by `add --create`), `verify=` (with `[verify] on_status = true`), and [`(STALE: ...)`](04-branching-and-writing.qmd#stale-working-copies). `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` do so (unless `--no-snapshot`). A commit refreshes the cache only for the objects it fingerprinted. ## `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.`, 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](12-caveats-and-performance.qmd#pin-then-commit-ordering)). ## `pull` The dataset's fetch, for the bookmark this working copy is on ([Positions](04-branching-and-writing.qmd#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` `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`: 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`); elsewhere it is `tether.ws..`, created by the first writable `open` (`defer-fork`) or now with `--eager`. A `pin = "record"` object's is created now either way, unless `--defer-record` ([Lazy or eager](04-branching-and-writing.qmd#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` (`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](14-troubleshooting.qmd#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` then shows the adopted writes as `modified`, and `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` | Handle | Address | | --- | --- | | `FileHandle` | `uri[#version_id]` | | `NeonHandle` | connection URL, password redacted (`--with-password` prints it; `--json` never does) | | `GitHandle` | `path@sha` | | `IcechunkHandle` | `uri#` | | `IcebergHandle` | `identifier#` | | `DeltaHandle` | `uri@vN` | | `LanceHandle` | `uri#tag` or `uri#branch@vN` | | `DuckLakeHandle` | `metadata#snapshot=N` | | `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` 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` is read-only. Each `open` re-reads `workspace.toml` and the manifests, so a long-lived `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`. ## `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` pins what you restored, and `promote` treats the branch as forked from `REV`. It reads each branch as the store has it, a fork `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` until the next `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` Per Forkable object, `promote` plans `fast-forward`, `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](04-branching-and-writing.qmd#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](04-branching-and-writing.qmd#throwing-it-away-tether-drop). ## `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](05-reclaiming-storage.qmd#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](05-reclaiming-storage.qmd#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](08-configuration.qmd#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` 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](04-branching-and-writing.qmd#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`, `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`, 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` 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`, `remove`, `set`, `import` | Restores the manifests; `undo add` also removes a still-empty store `--create` made. | -- | | `promote` | -- | Always. It prints each base branch's head before the move: tether never moves a base backwards. | | `repair`, `upgrade`, `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..2`, `.3`, ...). The pin id and the manifests stay, `verify` reports `ok pinned as tether..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` 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`: 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- -- TRUNK` (`tether new -- recover-` 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...` 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` 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](14-troubleshooting.qmd#recovering-a-lost-dataset-repo). ## `upgrade` `tether.toml` carries `[tether] version`, now 5. Every command but `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. ::: {.callout-warning title="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` policy, no bookmark (alpha) | Local `file` states become content hashes, the `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` With no arguments, `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` too (`why` in `--json`). `--content` adds native diffs, and `--limit` caps the entries printed per object. ## `export`, `publish`, `import` Experimental; see [Registries and SQL](10-registries-and-sql.qmd). ## JSON output | Command | Shape | | --- | --- | | `init` | `{"root": "", "dataset_id": "<8 hex>"}` | | `backends` | `[{"kind", "installed", "maturity", "tier", "capabilities": [names], "note": str or null}]` (an uninstalled extra: `installed` false, `maturity` and `tier` null, `capabilities` `[]`, `note` the import error) | | `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` is false when the cached snapshot from `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` | `{"vcs_commit": sha or null, "pinned": {key: ref or null}, "unrecoverable": [keys], "unchanged": [keys]}`; with `--dry-run`: the plan | | `new` | `{"bookmark", "working_refs": {key: ref}, "pending_forks": {key: ref}, "fork_points": {key: state}, "adopted": {key: ref}}`; with `--dry-run`: the plan | | `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` | `{"key", "read_only", "address", "type"}` | | `restore` | `{"from": rev or null, "at": ref or null, "working_refs": {key: ref}}`; with `--dry-run`: the plan | | `verify` | `{label: {"status", "message"}}` with labels `key` or `:key` | | `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` 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` | `{"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` merges every object's; a step's `command` is a shell command line with every value quoted, null where there is nothing to run; `scoped` (keys given) means no id and nothing that pins is suggested | | `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`. `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 | ### Configuration What a dataset is made of on disk, what `tether.toml` can say, where credentials and other per-checkout settings live, and what a cloned dataset can and cannot make tether do. ## Layout ``` / tether.toml # committed: format version, dataset id, snapshot/verify/new settings, # default policies, non-secret backend options .tether/ .gitignore # ignores workspace.toml, ops.jsonl, secrets.toml, cache/, lock secrets.toml # per checkout: credentials or references, endpoints, executables, SQL objects/.toml # committed, one per object: kind, locator, policy, state, pin, # recoverable, captured_at, origin (only when "created") listings/.jsonl # committed, content-addressed per-file listings of directory and # prefix states (for `diff --content`); pruned by gc workspace.toml # per checkout: workspace id, bookmark, working refs, pending forks, # base states, fork points, last snapshot ops.jsonl # per checkout: the operation log / # .jj/repo/ or .git/: one per clone, shared by its checkouts, never committed tether.lock # the repository-wide lock (see Caveats) tether-pinned.jsonl # pins this clone created: the only ones gc releases by default tether-created.jsonl # stores this clone made with `add --create` tether-touched.jsonl # stores this clone forked a branch in or pinned ``` The three per-checkout files are refused while the VCS tracks them (the [security model](#security-model)). The indexes live with the repository, not in history or a checkout: an abandoned fork takes the manifest that named a store with it, and `forget-workspace` takes the checkout's files. They are memories, not authority. [Reclaiming Storage](05-reclaiming-storage.qmd) says how `gc` uses them, and why a re-clone starts without them. `pin_id = .`, and the native ref is `tether.`. An identical state yields the same pin within a dataset: re-committing an unchanged object is a no-op, and identical states dedupe to one pin. The identity is the backend's canonical form of the locator, such as a resolved local path ([Backends](09-backends.qmd#conventions)). ## `tether.toml` A dataset's configuration is `tether.toml` at the dataset root, committed with the manifests and loaded into `RepoConfig`. Every key but `[dataset] id` has a default. A dataset whose version is older than this tether's is refused by every command except [`tether upgrade`](07-cli.qmd#upgrade). ```toml [tether] version = 5 # the format this tether writes; older datasets need `tether upgrade` [dataset] id = "0a1b2c3d" # 8 hex chars; the namespace of this dataset's pins and branches in every store [snapshot] auto = false # `status` shows the last snapshot (--snapshot refreshes); true: fingerprint on every status [verify] on_status = false # also run a cheap verify of each committed object in status [new] fork = "lazy" # "lazy": create a working branch on first writable open; "eager": during `new` record = "fork" # pin=record objects: "fork" during `new` even when lazy; "defer": like the rest (needs fork = "lazy") [defaults] # policy for objects added without an explicit one file = "immutable" # immutable | versioned pin = "native" # native | record [vcs] prefer = "jj" # which adapter to use when both .jj and .git exist trunk = "main" # the bookmark that stands for every object's upstream branch [backends.file] # non-secret options only; one table per kind storage_options = { region = "us-west-2" } ``` ## Dataset id `init` generates `[dataset] id`, and every clone shares it. Every native ref carries it (pins `tether..`, working branches `tether.ws..`), and `gc` stays inside that namespace. So datasets can share one Icechunk repository or Neon project without deleting each other's pins. Datasets from before the id existed get one from `tether upgrade`. `tether init --dataset-id ID` (`Repo.init(dataset_id=...)`) uses the id you give, which must be 8 lowercase hex characters. A dataset whose repository was lost gets its stores' pins and branches back this way, and `tether recover` finds the id when you do not know it ([Recovering a lost dataset repo](14-troubleshooting.qmd#recovering-a-lost-dataset-repo)). ::: {.callout-warning} ## Do not change the id after committing Before anything is pinned the id can still change; that is how `tether recover` takes back a lost dataset's refs. After that: - Pins and branches made under the old id stop being this dataset's: `gc` treats them as foreign and leaves them alone. - `repair` recreates the pins the manifests name, under the new id. - A fork meant to become an *independent* dataset must change the id *before* it pins anything, or the two share a namespace and can `gc` each other. ::: ## Policies A `Policy` is stored per object (`[policy]` in its manifest). It is set with the `--file` and `--pin` options of `tether add` (or `Repo.add(policy=...)`) and changed with `tether set KEY... --file|--pin`. Whether writes fork or land upstream is not a policy: the trunk writes upstream, and any other bookmark forks. Manifests from before 0.1.0a9 carry a `write` key, which is ignored and dropped by `tether upgrade`. | Field | Values | Meaning | | --- | --- | --- | | `file` | `immutable` (default), `versioned` | `file` backend only. `immutable`: any drift is an error at `status`. `versioned`: a single object in a versioning-enabled bucket or container is Addressable through its version id. | | `pin` | `native` (default), `record` | Any backend. `record` drops `PIN`: `commit` records the state without a native ref, and `new` forks from the recorded state. Cheaper, but recoverable only while the system keeps that state ([Lever 1](05-reclaiming-storage.qmd#lever-1-do-not-pin-what-you-will-not-need---pin-record)). | ## Backend options `[backends.]` is handed to the backend when it is first used. The committed file may set only what a backend allowlists: `storage_options` for `file`, `delta` and `lance`, and `catalog` for `iceberg`. Inside those tables only the named keys pass (`region`, `allow_http`, `virtual_hosted_style_request`, `conditional_put`; `type`, `warehouse`, `name`). Any other key is refused by name, so an option tether has never heard of cannot reach a store from a clone. Everything else goes in `.tether/secrets.toml`, where the same tables may hold any key. A table set in both files is merged field by field, and `secrets.toml` wins a field both set. So a committed `catalog` keeps its `type` and `warehouse` when `secrets.toml` adds only the catalog's `uri`. Per-backend keys: [Backends](09-backends.qmd). ## Trust boundary: `.tether/secrets.toml` {#trust-boundary-tethersecretstoml} A cloned dataset is untrusted input. `tether.toml` and the manifests arrive with every clone, so nothing in them may choose an executable, an endpoint your credentials go to, SQL to run, or the variable a secret is read from. tether refuses a committed `tether.toml` that tries (`[vcs] git_path`, `[import] query`, `[backends.neon] api_url`, `[backends.ducklake] init_sql`, a `storage_options.endpoint`, ...), and says where the key belongs: `.tether/secrets.toml`, per checkout and never committed, or the environment. ```toml [vcs] git_path = "/usr/bin/git" # or TETHER_GIT / TETHER_JJ in the environment # jj_path = "/opt/jj/bin/jj" [backends.neon] # per-kind options the committed file may not set api_key_env = "NEON_API_KEY" [backends.ducklake] init_sql = ["CREATE SECRET (TYPE S3, PROVIDER credential_chain)"] [uris."s3://lab-a-bucket/"] # every object whose store URI starts here profile = "lab-a" # an AWS shared-config profile ... # role_arn = "arn:aws:iam::123456789012:role/lab-a" # ... or a role, assumed via STS ... # access_key_id = "..." # ... or, when nothing else will do, literal keys # secret_access_key = "..." # endpoint_url = "http://seaweedfs.local:8333" # allow_http = true # icechunk: plain HTTP to that endpoint # force_path_style = true # icechunk: bucket in the path, not the host [objects."zarr/imaging"] # one object; beats a URI prefix profile = "imaging" [uris."mysql://dolt.internal:3306"] # a Dolt server: the only one sent this password user = "svc" # (Dolt reads server entries only) password_env = "DOLT_PASSWORD" [import] # the default SQL for `tether import` (see Registries and SQL) query = "SELECT name AS key, 'file' AS kind, uri FROM catalog" ``` Resolution per object and per field, the most specific first: `[objects.""]`, then the longest matching `[uris.""]`, then `[backends.]`, then the environment and instance credential chain. A table given at two levels is merged, not replaced. Per-object entries also reach `add --create` and `gc --delete-stores`. `file`, `delta` and `lance` take the entries as storage options, and `icechunk` passes them to `s3_storage`. Dolt honours only the server's `[uris."mysql://host:port"]` entry (or one for `mysql://host:port/database`), because the manifest chooses the host a password is sent to. A `profile` or `role_arn` is resolved through `boto3` (install it when you use them) into explicit credentials. Concurrent callers share one resolution, cached until five minutes before the keys expire. Icechunk takes a profile or role as a refreshing credential source, so a session held for hours gets fresh keys instead of failing mid-write. The file may hold literal credentials, so tether warns when other users can read it (`chmod 600`) and never prints its contents: not in `status`, not in `--json`, not in an error. `init` lists it in `.tether/.gitignore`, and `Repo.find` adds the entry to an older dataset that lacks it. ### Advanced: two stores, two identities The case the per-URI and per-object entries exist for: a dataset tracks an Icechunk repository in the imaging lab's account and another in the sequencing core's, and one `commit` pins both. With `from_env` alone that is an `AWS_PROFILE` switch between commands, which one `commit` cannot do. ```bash tether add zarr/imaging s3://imaging-lab-data/plate-01.icechunk --kind icechunk tether add zarr/reads s3://seqcore-archive/run-2026-09.icechunk --kind icechunk ``` ```toml # .tether/secrets.toml (this checkout only; never committed) [uris."s3://imaging-lab-data/"] # every object in the imaging bucket profile = "imaging-lab" # a profile in ~/.aws/config [objects."zarr/reads"] # one object, whichever bucket it is in role_arn = "arn:aws:iam::210987654321:role/seqcore-reader" region = "us-east-2" ``` `tether commit` now opens `zarr/imaging` with the `imaging-lab` profile's keys and `zarr/reads` with a session assumed into the sequencing core's role, in one process. A third object with no entry keeps the ambient environment, and the manifests record only the two URIs. On CI the same layout works with literal keys the runner writes into the file before the first command. tether does not expand `${...}` itself, so write the values in, or use a role the runner's identity can assume ([A CI recipe](13-sharing-and-ci.qmd#a-ci-recipe)). ## Security model What a cloned dataset (its `tether.toml`, manifests, history, and every other file in the checkout) can and cannot make tether do. It cannot: - **Run a program.** Executables come from `secrets.toml` or the environment. `secrets.toml`, `workspace.toml` and `ops.jsonl` are refused while the VCS tracks them, since a clone brings a tracked copy with it. `find` checks, and so does every later refresh (each `open`, each writing command) that finds `workspace.toml` changed. The check runs before `secrets.toml` is read, so a jj reachable only through its `jj_path` needs `TETHER_JJ`. - **Run git code through a `git` object.** The `git` backend runs with `core.fsmonitor`, hooks and `ext::` transports off, and never takes a directory for a bare repository by itself (`safe.bareRepository`, which is why git 2.38 is required). A `git` object's `path` must be absolute and outside the checkout, whose files came with the clone. - **Choose where writes go, or which pins `gc` releases.** Those are `workspace.toml` and the op log, refused when tracked. Another checkout's tracked copy is skipped with a warning naming the checkout. - **Send your credentials somewhere.** Endpoints, and Icechunk's `allow_http` and `force_path_style`, come from `secrets.toml` only. A Dolt password goes only to a server `secrets.toml` lists. - **Run SQL.** `[import] query` and `init_sql` come from `secrets.toml` or the command line. An import query runs as one statement in a read-only transaction. - **Push your code.** A git object's `remote` must name a remote the repository already has. - **Write outside `.tether/`.** Object keys are checked when a manifest is read, not only at `add`: no `..`, no `\`, no drive letters. It can: - **Name stores.** A locator is an address. A manifest can point `status`, `commit` and `verify` at any store your ambient credentials reach, and they sign the requests (a signature does not reveal the secret). `commit` records what it finds, a directory's file names and hashes included, and pushing publishes that. - **Release pins in your stores, with your help.** `gc` releases only pins in its dataset's namespace that no manifest names and that this clone created. `--release-foreign` drops the last condition. In a clone that reuses another dataset's id and names its stores, that releases the other dataset's pins. - **Make commands fail.** Every refusal above is an error: a hostile clone can stop a command, not redirect it. So read a clone's manifests before its first `commit`, and a `gc` plan before applying it. The dataset's own repository runs your git hooks on dataset commits; those live in your `.git/`, which no clone brings. ## Workspace state `.tether/workspace.toml` is per checkout (per jj workspace or git worktree): ```toml workspace_id = "7c1e0a4d9b2e4f6a8c1d3e5f7a9b0c2d" bookmark = "relabel" # the dataset bookmark this checkout works on (absent: read-only) last_snapshot_at = "2026-09-04T22:10:03.118Z" [working_refs] # branches that exist (on the trunk: the upstream branches) "zarr/imaging" = "tether.ws.0a1b2c3d.relabel" "db/metrics" = "tether.ws.0a1b2c3d.relabel" [pending_forks] # decided by `new`, created on first writable open "features" = "tether.ws.0a1b2c3d.relabel" [pending_resets."features"] # the head `new` reviewed and agreed to reset on that open version = 9 # (a branch that moved since is refused instead) [base_states."zarr/imaging"] # committed state this working ref corresponds to snapshot_id = "K3F7Q2ZJ1XN4Y8W0" # (differs from the manifest => stale) [fork_points."zarr/imaging"] # base state when the branch was forked (for promote) snapshot_id = "K3F7Q2ZJ1XN4Y8W0" [last_snapshot."zarr/imaging"] # what a plain `status` shows (errors are kept too) snapshot_id = "K3F7Q2ZJ1XN4Y8W0" ``` A writable `open` moves an entry from `pending_forks` to `working_refs`. `pending_resets` keeps it from resetting a branch that moved since `new`, and `base_states` drive the stale check. Every `open` re-reads the file, so a long-lived `Repo` follows what another process's `new` wrote. ## Operation log `.tether/ops.jsonl` is this checkout's operation log: one JSON line per store-writing command, appended only, like jj's `op log`. The VCS records the manifests, but it cannot see which refs `commit` created, which branches `new` forked or reset, what `gc` deleted, or where `promote` moved a base branch from. Each entry holds the plan it applied, what it did, and what it replaced. `tether ops` lists the entries, and `tether undo` reverses from them. The log is a journal. A store-writing command appends its entry and syncs it *before* the first side effect, appends a progress record as each effect lands, and marks completion with the result. A process killed in between leaves an `INCOMPLETE` entry. `undo` refuses it, `repair --dry-run` says how far it got, and running the command again finishes what is left. The `new` and `gc` a `drop` runs are journaled as its steps (`parent`). An interruption loses at most the line being written, and the next entry starts on a fresh line. Other commands read the log too, so deleting it loses more than `undo`: - `gc` keeps every pin an unfinished operation in any live checkout set out to create; - `status` and `ops` flag dataset commits that vanished from the VCS; - the first `gc` seeds the [pin index](05-reclaiming-storage.qmd#pins-this-clone-did-not-create) from the logs, so pins whose entries are gone by then are kept as another clone's. ## Environment variables Every variable tether reads (`TETHER_REV`, `TETHER_JJ` / `TETHER_GIT`, `TETHER_NATIVE`, `TETHER_PUBLISH_DSN`, `NEON_API_KEY`, the Dolt `*_env` names, the cloud credential chains) is in one table in the [CLI Guide](07-cli.qmd#versions-and-environment), with the `GIT_*` variables tether ignores. ### Backends One section per backend: its extra, tier and flags, the locator fields it reads, its `[backends.]` config, its state, how pins and forks map onto the system, its handle, and what `diff --content` reports. Credentials are never stored in manifests. | Kind | Extra | Maturity | Tier | Flags | | --- | --- | --- | --- | --- | | [`file`](#file-local-paths-and-object-stores) | -- (local) / `objectstore` | stable | Observed; Addressable for a versioned remote object | `CHEAP_FINGERPRINT`, `DIFF`, `CREATE` (local directory) | | [`icechunk`](#icechunk-icechunk-repositories) | `icechunk` | stable | Forkable | `ATOMIC_REF`, `DIFF`, `HISTORY`, `PROMOTE`, `CREATE`; `CONDITIONAL_REF` on `s3://` | | [`git`](#git-git-and-jj-repositories) | -- | stable | Forkable | `CHEAP_FINGERPRINT`, `ATOMIC_REF`, `CONDITIONAL_REF`, `DIFF`, `HISTORY`, `PROMOTE`, `MERGE`, `CREATE` | | [`lance`](#lance-lance-datasets) | `lance` | stable | Forkable | `ATOMIC_REF`, `DIFF`, `HISTORY` | | [`delta`](#delta-delta-lake-tables) | `delta` | stable | Addressable | `CHEAP_FINGERPRINT`, `RETENTION_BOUND`, `DIFF`, `HISTORY` | | [`neon`](#neon-neon-postgres) | `neon` | experimental | Forkable | `NEEDS_QUIESCENCE`, `RETENTION_BOUND`, `BRANCH_IS_STORAGE` | | [`iceberg`](#iceberg-apache-iceberg-tables) | `iceberg` | experimental | Forkable | `RETENTION_BOUND`, `DIFF`, `HISTORY`, `PROMOTE` | | [`ducklake`](#ducklake-ducklake-catalogs) | `ducklake` | experimental | Addressable | `CHEAP_FINGERPRINT`, `RETENTION_BOUND`, `DIFF`, `HISTORY` | | [`dolt`](#dolt-dolt-databases) | `dolt` | experimental | Forkable | `ATOMIC_REF`, `DIFF`, `HISTORY`, `PROMOTE`, `MERGE` | | [`memory`](#memory-in-process-reference-backend) | -- | stable | Forkable | `CHEAP_FINGERPRINT`, `ATOMIC_REF`, `CONDITIONAL_REF`, `DIFF`, `HISTORY`, `PROMOTE`, `MERGE`, `CREATE` (tests only) | *Maturity* is what the backend declares (`MATURITY`), and `tether backends` prints it. **Stable** means the full lifecycle (register, commit, fork, write, commit, promote, gc) runs in CI against the real system: embedded stores and libraries, on local storage. The S3, GCS and Azure paths of `file` and `icechunk` are **not yet cloud-tested**; `ROADMAP.md` tracks that. **Experimental** means tested against a fake of a network service, not the service itself, and `add` says so. The tiers and flags are defined in [Writing a Backend](11-extending.qmd#capabilities-decide-behavior). ## Conventions - `branch` in a locator is the *upstream* branch (default `main`), which the trunk bookmark stands for. Other bookmarks fork `tether.ws..` from the pin. - Pins are named `tether.`, where `pin_id` is `.`: the dataset's id, then a content address of kind, identity and state. Backends treat the id as opaque. - `identity` is the subset of the locator that names the system. Two objects of one dataset with the same identity and state share one pin, which `commit` cuts once; the second object is recorded at the state the pin was taken at. - A local path's identity is its resolved form: symlinks followed, `.`, `..`, doubled and trailing slashes gone, and `file://` folded. So `/p`, `/p/`, `file:///p` and a path through a symlinked parent are one store to pin ids, listings and `gc`. Re-pointing a symlink therefore changes the store's identity: its pins stay safe (`gc` never releases a pin any manifest names), but listings stored under the old identity are not found. - Locators are committed and read from every directory and clone, so a *relative* local path is resolved against the directory `add` or `import` ran in, and stored absolute; a leading `~` is expanded. That covers `file` `uri`/`path`, `git` `path`, the `icechunk`, `lance` and `delta` `uri`, and DuckLake's `data_path` and the path in `metadata` (after `ducklake:`, `duckdb:` or `sqlite:`). URLs and absolute paths are stored as given. - Objects in one *branch scope* (two databases of a Neon project, two keys on one Icechunk store) share a branch per bookmark, and `gc` collects their references per scope. - `at` is an object's initial position (`tether add --at` / `--pick`), pinned by the first commit until a trunk `pull` moves the object onto the branch. What each system accepts, and what `tether log` lists: | Kind | `at` accepts | `log` lists | | --- | --- | --- | | `icechunk` | snapshot id, tag, branch | `Repository.ancestry` from the ref: id, `written_at`, message | | `lance` | version number (on the base branch) or tag | `versions()` of the branch, newest first, tags on each | | `iceberg` | snapshot id or ref name | snapshot ancestry with operation and record counts | | `delta` | version number | transaction log with operation and metrics | | `ducklake` | snapshot id | `ducklake_snapshots` with each snapshot's changes | | `dolt` | commit hash, tag, or branch (`HASHOF`) | `DOLT_LOG(ref)` | | `git` | any commit-ish (alias of `ref`) | `git log` with decorations | | `neon`, `file` | not supported | -- | ## What the flags mean in practice **`PROMOTE` and `MERGE`.** `PROMOTE` fast-forwards the base branch to a fork (Icechunk `reset_branch`, Iceberg `set-snapshot-ref`, git `update-ref` or `merge --ff-only`, Dolt merges). `MERGE` three-way merges a diverged fork (git `merge`, `DOLT_MERGE`). Lance cannot move a branch head yet, and Neon cannot promote a child branch, so both are refused with instructions. Where Icechunk and Lance stand upstream on branch merge, and what blocks it, is a dated survey in [`docs/upstream-branch-merge.md`](https://github.com/Rosebud-Biosciences/tether/blob/main/docs/upstream-branch-merge.md). **`CONDITIONAL_REF`** means a fork, promote or merge moves the ref only from the head the plan reviewed, in one atomic step. The others compare just before the move ([conditional moves](07-cli.qmd#conditional-moves)). **`CREATE`** *(experimental)* is what `tether add --create` needs ([Stores tether created](05-reclaiming-storage.qmd#stores-tether-created)). Lance, Delta and Iceberg tables need a schema, so they cannot be created empty. Neon, Dolt and DuckLake objects live inside a project, database or catalog tether does not own. Make those with their own tools and `add` them. `--pin record` removes `PIN` from any backend ([Lever 1](05-reclaiming-storage.qmd#lever-1-do-not-pin-what-you-will-not-need---pin-record)). ## State keys: content and address A state's keys do two jobs: - **Content** keys say what the data is: an Icechunk snapshot id, a Neon `commit_xid`, a git sha. Equality, pin ids and listing names use only these. - **Address** keys say where to find it, and can move without the data changing: Neon's `lsn`, and git's `change_id` and `dirty`. Backends list them in `VOLATILE_KEYS`; `open`, `pin` and `verify` get the whole state. ## Content diffs `tether diff A B` compares manifests. `--content` also asks each changed object's backend for its *native* diff, from metadata only, concurrently: | Kind | Unit | Source | Example detail | | --- | --- | --- | --- | | `file` (dir / prefix) | files | two stored listings (`.tether/listings/`) | `modified a.tif +4.0 KiB` | | `file` (object) | objects | recorded size / etag / version | `etag a -> b, version v1 -> v2` | | `git` | files | `git diff --name-status` + `--numstat` | `modified main.py +1 -1` | | `icechunk` | nodes | `Repository.diff(from, to)` | `modified /x 2 chunks` | | `iceberg` | snapshots | snapshot summaries along `b`'s ancestry to `a` | `append: +5 rows, +1 files` | | `delta` | commits | transaction-log `history()` in `(a, b]` | `WRITE: +2 rows, +1 files` | | `lance` | fragments | fragment ids / deletions / schema at both versions | `added fragment 1 +1 rows` | | `ducklake` | tables | snapshot `changes` + `ducklake_table_changes` | `modified main.t +5000 rows, -1 rows` | | `dolt` | tables | `dolt_diff_summary` + `dolt_diff_stat` | `modified t +6 rows` | | `neon` | -- | no native diff | | A directory or prefix state is only a digest, so at commit time the `file` backend also hands the engine a per-file listing. It is stored content-addressed under `.tether/listings/` and committed with the manifests, like DVC's `.dir` files. `diff --content` compares two listings file by file, or falls back to a count and size summary. Entries are capped at 2000 per object; the counts are exact. ## `file`: local paths and object stores Plain artifacts: a local file or directory, or an object / prefix in S3 (`s3://`), GCS (`gs://`), or Azure Blob (`az://`, `abfs://`), through [obstore](https://developmentseed.org/obstore/). | | | | --- | --- | | Locator | `uri` (positional; `path` accepted). A trailing `/` (or empty key) means a prefix. `region` overrides the store region. | | Config | `storage_options = {...}` passed to `obstore.store.from_url`. Committed: only `region`, `allow_http`, `virtual_hosted_style_request` and `conditional_put`; endpoints, account names and credentials only in `.tether/secrets.toml`. HTTP client options (`allow_http`, timeouts, proxies, ...) work in any spelling there (`AWS_ALLOW_HTTP`, `aws_allow_http`, or inside `client_options`); a number is a duration in seconds, and an unknown key raises `ConfigError`. Otherwise credentials come from the environment or instance metadata. | | Identity | `{uri}`, a local path in its resolved form ([Conventions](#conventions)) | | State | local file: `type=file, size, sha256`; local dir: `type=dir, count, size, digest` (digest over per-file sha256s); remote object: `type=object, size, etag[, version_id]`; prefix: `type=prefix, count, size, digest` (one paged `LIST`, etag+size per key). Local hashes are cached by `(size, mtime_ns, ctime_ns, inode)` in `.tether/cache/file-hashes.json`, so only files whose stat changed are re-read | | Tier | Observed by default. `--file versioned` on a single *remote* object makes it Addressable via `version_id` (S3 version id / GCS generation / Azure version id; requires versioning on the bucket/container -- a recorded state without a version id is refused by `open`). Local paths and prefixes are always Observed. | | Pin / fork | none | | Handle | `FileHandle(uri, version_id)` -- always read-only | | Listing | directories and prefixes hand the engine a per-file JSONL listing (`{"p": path, "k": token, "s": size}`) that is stored under `.tether/listings/` | | Diff | `files`: per-path added/removed/modified with size deltas from two stored listings; falls back to a count/size summary when a listing is missing; single objects report changed size/etag/sha256/version | | Create | a local directory only (`mkdir` plus a `.tether-owner` marker file the directory walk skips, so the directory fingerprints as empty until something is written). Ref-empty means nothing but the marker; `delete_store` is an `rmdir` that refuses anything else. Remote prefixes are not created or removed by tether. | | Notes | `--file immutable` (default) turns any drift into an `ImmutableObjectModified` error at `status`; drift means the *bytes* changed, not the timestamp. A symlink to a file is read through. A symlink to a directory, or a dangling one, is recorded by its target: retargeting it is a change, but edits under a linked directory are not seen. Fingerprint cost: [Performance](12-caveats-and-performance.qmd#fingerprints-are-metadata-only-except-the-first-local-one). | ## `icechunk`: Icechunk repositories | | | | --- | --- | | Locator | `uri` (local path or `s3://bucket/prefix`), `branch` (default `main`), `region` (S3) | | Config | none committed. `.tether/secrets.toml` may set `profile`, `role_arn`, literal keys, `endpoint_url` and `region` per URI prefix or object ([two identities](08-configuration.qmd#advanced-two-stores-two-identities)); otherwise S3 credentials come from the environment (`from_env`). A profile or role becomes a `get_credentials` callback that refreshes the keys, so a long-lived session does not expire mid-write; an icechunk without the callback gets static keys, rebuilt when they rotate. For an S3-compatible server (SeaweedFS, MinIO), `allow_http = true` and `force_path_style = true` in the same entry reach every Icechunk and obstore call, `add --create` and `delete_store` included. | | Identity | `{uri}`, a local path in its resolved form | | State | `snapshot_id` of the branch head | | Pin | `create_tag("tether.", snapshot_id)`; tags are immutable, and the default `expire_snapshots` keeps a tagged snapshot. `expire_snapshots(delete_expired_tags=True)` deletes the tag and the snapshot with it; `verify` then reports the pin `missing` | | Fork | `create_branch(name, snapshot_id)` off the tag, or `reset_branch` if it exists. A conditional move uses `reset_branch(from_snapshot_id=)` (icechunk 2.x), which is atomic on object stores only: on local filesystem storage racing processes can each win | | Handle | `IcechunkHandle(repository, session, branch, tag, snapshot_id)`; `session` is writable for a branch, read-only for a tag/snapshot | | Diff | `nodes`: `Repository.diff(from, to)` -- new/deleted groups and arrays, updated metadata, updated chunk counts per array, moves | | Create | `Repository.create`, refused if anything exists at the path or under the prefix. The owner marker is the `tether.owner` key in the repository's metadata; an icechunk without repository metadata (2.2.0 works) lacks `CREATE`. Ref-empty: no branch but the base, no tag beyond the pins the plan names, and the base still at the first snapshot. `delete_store` re-checks owner and emptiness, then removes only Icechunk's own top-level names; anything else there is refused and kept. | ## `neon`: Neon Postgres *Experimental*: tested against a fake of the Neon API, not a live project. | | | | --- | --- | | Locator | `project_id`, `database`, `role` (all required, since Neon builds no connection URI without them), `branch` (default `main`) | | Config | `api_key_env` (default `NEON_API_KEY`), `api_url` (default Neon API v2), `protected_pins` (default `false`; see [Caveats: Neon](12-caveats-and-performance.qmd#neon)), in `.tether/secrets.toml` only | | Identity | `{project_id, branch}`: a Neon branch at an LSN is a snapshot of the whole project, so `database` and `role` do not change pin ids. Two objects on two databases of one project share one pin per commit, and one working branch per bookmark | | State | `lsn`, `commit_xid`, `branch`, `timeline`, probed over SQL. Content is `commit_xid` (the newest *committed* transaction id) and `branch` (the lineage); `lsn` and `timeline` (the branch the LSN was read on) are volatile. With no commit among the last 65536 ids, `commit_xid` reads `` of the state's timeline at its LSN (so a fork's pin hangs off the fork, not `main`), protected only with `protected_pins = true`; `RETENTION_BOUND` by the project's history retention | | Fork | a child branch of the pin. Re-forking an existing working branch restores it onto the source. Once a pin hangs off the working branch Neon will not restore it in place, so the fork lands on a sibling (`.2`, `.3`, ...) while the old branch stays the pins' parent, and the engine records the returned name. A branch tether reads or writes gets one `read_write` endpoint, created once however many objects ask at the same time; pins and time-travel reads get a `read_only` one, and each connection URI names its endpoint | | Quiescence | `commit` refuses while `pg_stat_activity` shows active writers (`--force` skips) | | Handle | `NeonHandle(url, branch)` -- a Postgres connection URL | | Diff | not supported (no native primitive) | | Notes | pins are branches, and branches are the storage: what that means for `gc`, `new`, `promote`, cost and fingerprinting is in [Caveats: Neon](12-caveats-and-performance.qmd#neon). | ## `git`: git and jj repositories Pin the *code* (or any git-tracked data) that produced a dataset. Colocated jj repositories work through their `.git`. | | | | --- | --- | | Locator | `path` (local checkout, stored absolute, and outside the dataset's own checkout; the positional locator works too: `tether add code --kind git ../code`), `ref` (default `HEAD`), `remote` (push pins there: the name of a remote the repository has configured; a URL or path only as `[objects.""] remote` in `.tether/secrets.toml`), `allow_dirty` (pin despite uncommitted changes) | | Config | `git_path`, `jj_path` -- `.tether/secrets.toml` `[vcs]` or `TETHER_GIT`/`TETHER_JJ` only; a committed value is refused | | Identity | `{path}` (resolved absolute) | | State | `sha`; `dirty` (only meaningful -- and only computed -- for the checked-out ref); `change_id` when jj is available. Both `dirty` and `change_id` are volatile: the same sha is the same state -- and pins identically -- whether or not jj is installed or the checkout has stray files; `pin` still reads the full state and refuses a dirty tree | | Pin | lightweight tag `tether.` at the sha. With `remote` set the pin is pushed and is only a pin once the push succeeds: a failed push fails the `pin` and removes the local tag; `unpin` deletes on the remote first and fails if that fails, so local and remote never diverge silently | | Fork | branch `name` at the tag's sha. A conditional move is `update-ref` with the old value, atomic; a branch checked out in any worktree is refused | | Handle | `GitHandle(path, sha, worktree)` | | Diff | `files`: `git diff --name-status -M` with `--numstat` line counts (`+a -d`) | | Create | `git init -b ` plus one empty root commit with a fixed tree, author and dates, so every created store has the same root sha (`GitBackend.EMPTY_ROOT_SHA`); the owner marker is `git config tether.owner`. Ref-empty: no branch or tag beyond the base, the base *at* that root sha, and a clean working tree, since an untracked, staged or ignored file is data. `delete_store` re-checks both before it removes the directory. | | Notes | remote-only (url) repositories are not supported yet. Every git call runs with `core.fsmonitor`, hooks and `ext::` transports off and `safe.bareRepository=explicit`, so a repository's config runs nothing; git 2.38 is required. `promote` and `merge` move a branch. With the default `ref = HEAD` that is the checked-out branch, and a detached `HEAD` (a colocated jj checkout always has one) is refused: set `ref` to the branch name. A merge, or a promote onto a checked-out base, compares the head and then merges in the checkout, so a commit landing in between is merged or refused, never discarded. | ## `iceberg`: Apache Iceberg tables *Experimental*: driven through a fake of the pyiceberg table API; not yet run against a catalog service. | | | | --- | --- | | Locator | `identifier` (`ns.table`; `table` accepted), `branch` (default `main`), `catalog_name` (default `default`), `catalog` (dict of pyiceberg catalog properties, or set once in config) | | Config | `catalog = {...}` pyiceberg properties. Committed (`tether.toml` or the locator): exactly `type`, `warehouse` and `name`; every other property -- `uri`, `token`, `credential`, ... -- only in `.tether/secrets.toml` (`[backends.iceberg] catalog` or per identifier) | | Identity | `{identifier, branch, catalog_name}` | | State | `snapshot_id` (nothing else: `metadata_location` changes on every table commit on any branch). A new table with no snapshot yet has `snapshot_id = -1`, Iceberg's "no current snapshot" | | Pin | tag `tether.`; with `--pin record`, the snapshot id alone, recoverable within snapshot retention (for S3 Tables and other catalogs where user refs disable maintenance). The empty state needs no tag: its pin verifies as held and opens at `snapshot_id = -1`, which pyiceberg's scans refuse rather than read today's data | | Fork | `create_branch(name, snapshot_id)`; an existing name is re-pointed (`set-snapshot-ref`). A table with no snapshot cannot be forked, so write it on the trunk first. Needs pyiceberg 0.11 or newer | | Handle | `IcebergHandle(table, ref, snapshot_id)` | | Diff | `snapshots`: one entry per snapshot on `b`'s ancestry back to `a`, from snapshot summaries (`append: +5 rows, +1 files`); when `a` is not an ancestor, `total-*` counters are compared instead | ## `delta`: Delta Lake tables | | | | --- | --- | | Locator | `uri` (positional), `region` (sets `AWS_REGION`) | | Config | `storage_options = {...}` passed to `DeltaTable`; committed, the same four keys as `file`, and the rest in `.tether/secrets.toml` | | Identity | `{uri}`, a local path in its resolved form | | State | `version`, `table_id` (metadata id -- a recreated table verifies as drifted) | | Pin / fork | none: Delta has no tags or branches. Versions stay readable until `VACUUM` (7-day default) / log retention (30 days). | | Handle | `DeltaHandle(uri, version, table)` -- read-only `DeltaTable` at the version | | Diff | `commits`: one entry per transaction-log commit in `(a, b]` with its operation and metrics (`WRITE: +2 rows, +1 files`) | ## `lance`: Lance datasets | | | | --- | --- | | Locator | `uri` (positional), `branch` (default `main`) | | Config | `storage_options = {...}` passed to `lance.dataset`; committed, the same four keys as `file`, and the rest in `.tether/secrets.toml` | | Identity | `{uri}`, a local path in its resolved form | | State | `branch`, `version`, and on any branch but `main` its `branch_id` (Lance version numbers are branch-scoped and restart when a branch is deleted and re-created, so the id tells two lives of one name apart; `--at N` means version `N` of the locator's `branch` -- it is a *base* coordinate and is never consulted once a working branch exists; use a tag to name a version on another branch) | | Pin | `tags.create("tether.", (branch, version))`; tagged versions are exempt from `cleanup_old_versions()` | | Fork | `create_branch(name, tag)`. Lance refuses to delete a branch a tag references, so a pinned working branch outlives its workspace and a re-fork under the same name picks `name.2`, `name.3`, ... A conditional fork onto an existing branch compares its head, then deletes and recreates it, which leaves a small window; one expecting "absent" is atomic | | Handle | `LanceHandle(uri, dataset, version, branch, tag)`; append with `lance.write_dataset(table, handle.dataset, mode="append")` | | Diff | `fragments`: fragment ids added/removed, deletion-file changes, columns added/removed, from the two manifests | | Notes | an untouched fork reports its parent's `(branch, version)`, so a no-op commit after `tether new` is a no-op. | ## `ducklake`: DuckLake catalogs *Experimental*: tested against a local DuckDB-file catalog, not a Postgres or MySQL catalog with an object-store `data_path`. | | | | --- | --- | | Locator | `metadata` (positional `uri` accepted; the `ducklake:` prefix is added if missing, e.g. `ducklake:postgres:dbname=lake`), `data_path`, `table` (scopes the handle only; snapshots are catalog-wide). A DuckDB or SQLite file path in `metadata` (bare, `duckdb:` or `sqlite:`), and `data_path`, are stored absolute with `~` expanded; a pre-0.1.0b4 relative path is made absolute by `tether upgrade` | | Config | `init_sql = ["CREATE SECRET ...", ...]` run on every fresh connection -- `.tether/secrets.toml` only (it runs code); credentials there or in the environment, never in the locator | | Identity | `{metadata[, data_path]}` | | State | `snapshot_id`, `snapshot_time_us` (epoch microseconds; a recreated catalog verifies as drifted) | | Pin / fork | none: DuckLake has no tags or branches. `ducklake_expire_snapshots` bounds how long a snapshot stays readable. | | Handle | `DuckLakeHandle(metadata, alias, connection, snapshot_id, attach_sql, data_path, table)`: a DuckDB connection with the catalog attached read-only (at `SNAPSHOT_VERSION` for a recorded state); `table_ref()` gives `alias.table`; `close()` / `with handle:` detaches | | Diff | `tables`: per-table created/dropped/altered/inserted/deleted from the snapshots' `changes` maps, with row counts from `ducklake_table_changes` for tables that still exist. A table is named by its path, a part that is not a plain identifier double-quoted: `sales.europe.orders`, `"a.child".t` | | Notes | a DuckDB-file metadata catalog can be attached only once per process: operations attach/detach per call (serialized per catalog), and an open handle or your own writer connection in the same process blocks `status`. Postgres/SQLite/MySQL catalogs have no such limit. | ## `dolt`: Dolt databases *Experimental*: tested against a fake of the Dolt SQL surface, not a running `dolt sql-server`. | | | | --- | --- | | Locator | `host` (default `127.0.0.1`), `port` (default 3306), `database` (required), `branch` (default `main`), `user`; or `url = mysql://user@host:port/database` | | Credentials | only the server's `[uris."mysql://host:port"]` entry in `.tether/secrets.toml` (a `mysql://host:port/database` entry beats it): `user` and `password`, or `user_env` and `password_env`. A manifest chooses the host, so a server with no entry gets no password (and user `root` unless the locator names one) | | Identity | `{host, port, database}` | | State | `commit` (branch head hash), plus `dirty = true` when `dolt_branches.dirty` reports uncommitted working-set changes (refused at pin time) | | Pin | `CALL DOLT_TAG('tether.', commit)` | | Fork | `CALL DOLT_BRANCH(name, tag)` (recreated with `-D` if it exists at another commit) | | Promote / merge | `DOLT_MERGE` (`--ff-only` to promote) inside an explicit transaction, the only way Dolt reports which tables conflict or violate a constraint. Those come back as `MergeConflict`, and the merge is rolled back | | Handle | `DoltHandle(url, database, ref, commit)`: a revision-database URL `mysql://user@host:port/db/` (tags and hashes are read-only in Dolt); `database_ref` is `db/ref` for `USE` | | Diff | `tables`: `dolt_diff_summary` (added/dropped/modified/renamed tables, schema changes) with `dolt_diff_stat` row counts | | Notes | talks to `dolt sql-server` / Hosted Dolt via PyMySQL; the SQL surface sits behind the `DoltClient` protocol (`tether.experimental.backends.dolt`) so other transports can be substituted. | ## `memory`: in-process reference backend The executable specification of the protocol, used by the conformance suite and the engine tests. Locator `{system, branch}`; state `snapshot_id`; tags and branches in a process-global `MemoryStore` (`tether.backends.memory.default_store()`). `MemoryHandle.read()` / `.write(payload)` operate on the current ref; `diff` reports changed payload keys. ## Advanced ### Registries and SQL An optional layer for teams that keep a data catalog. Publish tether's history as SQL tables that dashboards and pipelines can join, or drive which objects tether tracks from a registry query. ::: {.callout-note title="Experimental"} `export`, `publish`, and `import` are tested and documented but sit outside tether's core loop, and the code lives under `tether.experimental.registry`. The commands print a one-line note saying so. The exported schema (`TABLES`) and the import columns (`CANONICAL_COLUMNS`) may change before 0.2; the commands, and the `tether.ExportBundle` / `tether.ImportSpec` re-exports, stay. See [Writing a Backend](11-extending.qmd#experimental-and-graduation) for what "experimental" promises. ::: Data registries keep one row per object in SQL, saying what exists and where. tether keeps its facts as TOML manifests in the VCS, which is what gives it diffs, merges, undo and a cheap history walk. Three commands treat SQL tables as a *derived view* of the manifests, never the other way round: | Direction | Command | What moves | | --- | --- | --- | | manifests -> file | `tether export` | commits, objects, states, refs as SQLite / Parquet / CSV / JSONL | | manifests -> database | `tether publish` | the same tables upserted into a Postgres schema | | registry -> manifests | `tether import` | which objects to track (and where to start), as `add` / `update` / `remove` | Nothing in this page changes how tether records or pins state: `commit` still does that, and only `commit` does. ## The tables Every output shares one schema (`tether.experimental.registry.TABLES`), so a SQLite export and a Postgres schema have the same columns. JSON columns are `TEXT` in SQLite and `JSONB` in Postgres; timestamps are ISO-8601 text and `TIMESTAMPTZ`. | Table | Key | Row | | --- | --- | --- | | `commits` | `commit_id` | one VCS commit: `change_id` (jj), author, `authored_at`, `committed_at`, `message`, `manifest_hash` | | `commit_parents` | `commit_id, parent_id` | one edge of the commit graph (`position` 0 = first parent) | | `refs` | `name, kind` | a bookmark / branch / tag / `head` and the commit it points at | | `objects` | `commit_id, key` | one manifest at one commit: `kind`, `uri`, `locator_json`, `identity_json`, `identity_hash`, `policy_*`, `state_json`, `state_hash`, `pin_id`, `pin_ref`, `recoverable`, `captured_at` | | `object_states` | `kind, identity_hash, state_hash` | one distinct (system, state): the *versions* a registry keys on, with `first_commit_id` | | `listings`, `listing_entries` | `listing_name` (`, path`) | per-file listings for directory / prefix objects (`--listings`) | | `workspace` | `workspace_id, key` | this checkout's bookmark branches (working refs), fork points, and last snapshot (`--workspace`) | | `tether_meta` | `key` | `schema_version`, `exported_at`, `head`, `vcs`, `tether_version` | Two views come along: `objects_head` (the objects at the exported head) and `object_pins` (each pin and how many commits reference it). `uri` is the locator's `uri` when it has one -- the natural join key to a catalog's URI column. `identity_hash` and `state_hash` are blake2b digests of the same canonical JSON that produces `pin_id`, so the same state on the same system hashes the same everywhere. ## Export ```bash tether export tether.sqlite # every reachable commit tether export tether.sqlite --rev main # one revision tether export dump/ --format parquet --listings # one file per table + schema.json tether export tether.sqlite --append # upsert into an existing file ``` `--append` never deletes: a jj commit that was later rewritten or abandoned keeps its rows. Rebuild without `--append` now and then, or let `publish` (below) do the incremental work against a database. Query it in place: ```bash sqlite3 tether.sqlite "SELECT key, pin_ref, state_json FROM objects_head" ``` ```python import duckdb con = duckdb.connect() con.sql("ATTACH 'tether.sqlite' AS t (TYPE sqlite)") con.sql(""" SELECT key, json_extract_string(state_json, '$.snapshot_id') AS snapshot FROM t.objects_head WHERE kind = 'icechunk' """).show() ``` Or skip the file: ```python from tether import Repo bundle = Repo.find(".").export(listings=True) tables = bundle.to_arrow() # dict[str, pyarrow.Table] bundle.to_sqlite("tether.sqlite") # or .to_dir("dump", "parquet") ``` ### Joining a registry Suppose a catalog has `catalog(uri, name, ...)` and a `runs` table records the VCS commit each job read as `tether_rev` (e.g. from `TETHER_REV`). Which Icechunk snapshot did each run read? ```sql SELECT r.run_id, c.name, json_extract_string(o.state_json, '$.snapshot_id') AS snapshot_id, o.pin_ref FROM runs r JOIN t.objects o ON o.commit_id = r.tether_rev AND o.kind = 'icechunk' JOIN catalog c ON c.uri = o.uri; ``` No tether process is involved; the export is a plain table. ## Publish `publish` writes the same tables into Postgres and is meant to run after every `tether commit`, from CI or an orchestrator step: ```bash export TETHER_PUBLISH_DSN=postgresql://tether@db.example.org/catalog # or --to tether publish --dry-run # per-table row counts, nothing written tether publish # schema `tether`; --schema to change it ``` ``` published 9 row(s) to schema 'tether' (12 commit(s) already present) commits 1 row(s) commit_parents 1 row(s) refs 3 row(s) objects 2 row(s) object_states 1 row(s) tether_meta 5 row(s) ``` It is idempotent and incremental. Commits are immutable, so rows of `commits`, `commit_parents` and `objects` whose commit is already in the schema are skipped. `refs`, `workspace` and `tether_meta` describe *now* and are rewritten. Everything runs in one transaction, with `INSERT ... ON CONFLICT DO UPDATE`. Tables and views are created `IF NOT EXISTS`, so only the first run needs `CREATE` on the database. Like `--append`, it never deletes commit rows: a jj commit rewritten after publishing keeps its row under its old id, which `objects_head` ignores. Drop and republish the schema when that matters. The DSN comes from `--to` or `$TETHER_PUBLISH_DSN`, never from `tether.toml` -- the same rule as backend credentials. Needs the `postgres` extra (`pip install 'tether-vcs[postgres]'`). From Python, `bundle.to_postgres(dsn, schema="tether")` returns a `PublishReport`; pass `connection=` to reuse an open connection or pool. ## Import `import` runs the other way. The registry says what to track; tether writes the manifests. Rows must have tether's canonical columns: | Column | Required | Meaning | | --- | --- | --- | | `key` | yes | object key | | `kind` | yes | backend kind | | `uri` | one of | becomes `locator.uri` | | `locator_json` | `uri` / `locator_json` | full locator as a JSON object (merged over `uri`) | | `policy_file`, `policy_pin` | no | default from `[defaults]` in `tether.toml` | | `at` | no | initial position, exactly like `add --at` | Other columns are ignored (and named in the plan). The mapping from your registry's columns is SQL on your side, which keeps tether free of per-registry options: ```bash tether import postgresql://reader@db.example.org/catalog --query " SELECT 'zarr/' || name AS key, 'icechunk' AS kind, uri FROM catalog WHERE format = 'icechunk' " --dry-run ``` ``` plan: import (2 write(s)) add zarr/run-041 [icechunk] s3://bucket/run-041.icechunk register update zarr/run-042 [icechunk] s3://bucket/run-042.icechunk locator changed; committed state kept zarr/run-043: unchanged ``` Sources: a Postgres DSN, a SQLite file (`--table` or `--query`), a `.csv`, or a `.jsonl`. The query runs as one statement in a read-only transaction. A standing query lives in `.tether/secrets.toml`, which `import` uses when neither `--table` nor `--query` is given. The committed `tether.toml` may not hold one, or its author would choose SQL that runs with every cloner's DSN. ```toml # .tether/secrets.toml [import] query = "SELECT 'zarr/' || name AS key, 'icechunk' AS kind, uri FROM catalog WHERE format = 'icechunk'" ``` Then `tether import postgresql://...` is enough. The plan has three verbs: - `add`: the key is new. - `update`: the key exists and its locator or policy differs. The manifest's committed `state` and `pin` are kept; the next `status` shows whether the object moved. - `remove`: only with `--sync`, for registered keys the source no longer lists. Like `tether remove`, this leaves working branches for `gc`. A key whose `kind` would change is refused: remove and add it again. `--plan FILE` and `--from-plan FILE` work as for other planned commands; an import plan checks the manifests, not the checkout. After importing, run `tether status` and `tether commit`: import changed *what is tracked*, and commit records *what state it is in*. ### Round trip The canonical columns are a subset of the `objects` table, so an export can be edited and imported back: ```bash tether export snap.sqlite sqlite3 snap.sqlite "UPDATE objects SET policy_pin = 'record' WHERE key LIKE 'scratch/%'" tether import snap.sqlite --sync --query " SELECT key, kind, locator_json, policy_file, policy_pin FROM objects_head " ``` An unmodified export imports as an empty plan. ## Prior art Files are canonical, tables are a rebuildable index -- every comparable tool lands on the same shape: | Tool | Shape | tether analogue | | --- | --- | --- | | [git-history](https://github.com/simonw/git-history) | a file's git history as `commits` / `item` / `item_version` tables | `export` / `publish` | | [Quilt](https://docs.quilt.bio) | package manifests in S3, Iceberg tables beside them | `export` / `publish` | | [Dolt](https://www.dolthub.com), Iceberg | version graph as `dolt_log` / `$snapshots` system tables | `export` / `publish` | | [Kart](https://kartproject.org) | git holds the data; a SQL database is the working copy; `kart commit` folds edits back | `import` then `tether commit` | | [Fossil](https://fossil-scm.org) | a VCS *in* SQLite whose SQL metadata `fossil rebuild` recomputes from the artifacts | the manifests are canonical | ### Writing a Backend For contributors adding a system tether does not know yet. A backend maps tether's operations onto one class of system. One instance serves every object of its kind, and per-object addressing travels in the `locator`. Implement the `ObjectBackend` protocol (`tether.backends.base`), register it, and run the conformance suite. This page also defines the capability flags, and what *experimental* means for a backend. ## The protocol ```python from tether.backends import Capability, ObjectBackend, ObjectDiff, VerifyReport, VerifyStatus, register_backend from tether.handles import Handle from tether.manifest import Locator, Pin, State, ref_for_pin class MyBackend(ObjectBackend): kind = "mysys" capabilities = ( Capability.FINGERPRINT | Capability.ADDRESSABLE | Capability.PIN | Capability.FORK | Capability.ATOMIC_REF | Capability.DIFF ) def __init__(self, config: dict | None = None) -> None: self._config = config or {} # [backends.mysys] from tether.toml def identity(self, locator: Locator) -> Locator: ... def fingerprint(self, locator: Locator, working_ref: str | None) -> State: ... def pin(self, locator: Locator, state: State, pin_id: str) -> Pin: ... def unpin(self, locator: Locator, pin: Pin) -> None: ... def list_pins(self, locator: Locator) -> set[str]: ... def verify(self, locator: Locator, state: State, pin: Pin | None, deep: bool) -> VerifyReport: ... def fork(self, locator: Locator, source: Pin | State, name: str, *, expected: State | None = None) -> str: ... def delete_working_ref(self, locator: Locator, ref: str) -> None: ... def open(self, locator: Locator, target: str | Pin | State | None, read_only: bool) -> Handle: ... register_backend("mysys", lambda config: MyBackend(config)) ``` Method by method: | Method | Contract | | --- | --- | | `identity(locator)` | The subset of the locator that names the *system* (not the branch, region, or credential references). It is hashed into the pin id, so two objects with the same identity and state share a pin. Pass a local path through `canonical_uri`, so every spelling of one directory is one identity. | | `fingerprint(locator, working_ref)` | The current state at `working_ref` (a branch `fork` returned) or at the locator's base ref when `None`. Must be a JSON-compatible dict whose *content* keys are stable across calls when nothing changed and different after any change. Keys that merely address the data (an LSN, a derived id) go in `VOLATILE_KEYS` so `content_state` excludes them from equality and pin ids; never include coordinates that move on unrelated activity (Iceberg's `metadata_location`). | | `VOLATILE_KEYS` (class attribute) | State keys that address but do not identify: excluded from drift detection, pin ids, listing names, and export hashes; still passed to `pin` / `open` / `verify`. Default: none. | | `pin(locator, state, pin_id)` | Create the native ref `ref_for_pin(pin_id)` (`tether.`) pointing at `state`. The id is opaque to the backend (`.`; the engine namespaces it). Idempotent: if it exists and points at the same state, return it; if it points elsewhere, raise `BackendError`. Raise if the state is no longer reachable -- `repair` relies on that. | | `unpin(locator, pin)` | Delete the ref. A ref that is already gone is a no-op or a `BackendError`, never a library exception. | | `list_pins(locator)` | Pin ids (without the `tether.` prefix) that exist natively, every dataset's included. The engine keeps only its own namespace before `gc` subtracts the referenced ones. | | `verify(locator, state, pin, deep)` | With a pin: `OK` if it resolves to `state`, `DRIFTED` if it points elsewhere, `MISSING` if gone. Without: `UNKNOWN` unless `deep`, then actually open the state. | | `fork(locator, source, name, *, expected=None)` | Create writable branch `name` and return the working ref (usually `name`; a backend may pick a sibling if `name` is taken). `source` is a `Pin`, or a recorded `State` for `pin = "record"` objects (raise `BackendError` if it is no longer reachable). **Reset contract**: an existing `name` is moved back onto `source` (a reset, a ref update, a branch restore, delete-and-recreate), and left alone when it is already there; conformance checks it. **Conditional move**: `expected` is the head the caller reviewed, a `State` or `ABSENT`; anything else raises `RefMovedError` and moves nothing. Use the system's compare-and-swap where it has one (git `update-ref `, Icechunk `reset_branch(from_snapshot_id=)`) and declare `CONDITIONAL_REF`; elsewhere call `check_expected(self, locator, expected, ref=name)` first. `None` moves unconditionally. A signature without the keyword is called without it, and the engine warns that its moves are unconditional. | | `delete_working_ref(locator, ref)` | Delete a branch `fork` created; never the base branch. Ignore "already gone". | | `working_ref_blockers(locator, ref)` | Why `ref` cannot be deleted right now, or `None`. `gc --prune-bookmarks` / `forget-workspace` plan a blocked branch as kept with the reason instead of failing at apply. Neon: pin children. Default: none. | | `rename_pin(locator, old, state, new_id)` | Give an existing pin a new id (`tether upgrade` when the naming scheme changes). Default: `pin` the state under the new id, then `unpin` the old -- right wherever pins are tags. Override where a pin is a branch with children (Neon renames in place). | | `rename_working_ref(locator, old, new)` | Rename a working branch; return the resulting ref. Default: `fork` `new` from `old`'s head state and delete `old`. | | `list_working_refs(locator)` | Native branches named `tether.ws.*` (`WORKING_REF_PREFIX`) in this system, every dataset's included; the engine filters to its own namespace. `gc --prune-bookmarks` fingerprints each stray one and deletes it only if its head is pinned or equals the base head (`--force-prune` overrides); `repair` uses it to notice a missing working branch. Default: none. | | `promote(locator, source, *, expected=None)` | `PROMOTE`: fast-forward the base branch to `source` (a working ref, `Pin`, or `State`); refuse with `BackendError` unless the base head is an ancestor of it, and no-op when already there. Return the base's new state. `expected` is the base head the plan reviewed: raise `RefMovedError` when the base holds another state, through the compare-and-swap where there is one, else `check_expected(self, locator, expected, what="promote")` first. | | `merge(locator, source, message, *, expected=None)` | `MERGE`: three-way merge `source` into the base; raise `MergeConflict(conflicts=[...])` and leave the base untouched on conflicts. Return the base's new state. The engine passes the *state* the plan reviewed, so merge that commit, not the branch head. Check `expected` with `check_expected(self, locator, expected, what="merge")` first: merges have no old-value form. | | `ancestor_of(locator, ancestor, descendant)` | Whether `ancestor` (a state) is in `descendant`'s history; `None` when unknown. `promote` falls back to it when no fork point was recorded. | | `PROMOTE_HINT` (class attribute) | The sentence `promote` shows when it refuses: what the user should do with the system's own tools. | | `open(locator, target, read_only)` | `target` is `None` (base ref), a `str` working ref, a `Pin` (read the pinned state), or a `State` (read an Addressable state). Return a `Handle` subclass carrying the native address and, where cheap, a native object. | | `create(locator, *, owner)` | `CREATE`: make an empty store at the locator, write an owner marker *in the store* naming `owner` (a dataset id), and return the initial state. Raise `BackendError` if anything is already there -- `create` never adopts. | | `owner(locator)` | `CREATE`: the dataset id in the store's owner marker; `None` when tether never wrote one or the store is gone. | | `is_ref_empty(locator, *, ignoring=())` | `CREATE`: `True` when nothing remains but the base branch at its initial state, the marker, and the refs in `ignoring` (the ones a gc plan is about to delete) -- and, for a store with a working area (a git checkout, a directory), no uncommitted content: files are data whether or not a ref names them; `False` otherwise; `None` when the backend cannot tell. `None` means keep. | | `delete_store(locator)` | `CREATE`: remove the store. Called only after `is_ref_empty` returned `True` and `owner` named the caller's dataset -- and expected to check both again itself, right before the irreversible step, and to remove only what the store's own layout accounts for. | The owner marker is the whole basis for a delete: manifests travel with clones and are untrusted, so a manifest field never authorizes one. Put the marker where the store keeps its own metadata (Icechunk repository metadata, `git config`, a `.tether-owner` file the directory walk skips) -- never in a ref that `list_pins` or `list_working_refs` would report. `pin` must be idempotent *and say which it did*: return `Pin(..., created=True)` when it made the ref, `created=False` when it found the ref already carrying the state. `commit` rolls back only the pins it created; a reused one belongs to the commit that made it. `unpin` and `delete_working_ref` must raise `BackendError` when the ref is still there afterwards -- "already gone" is success, a refused deletion is not. Optional members: - `effective_capabilities(locator, policy) -> Capability` when the tier varies per object (the `file` backend is Observed for a local directory and Addressable for a versioned remote object). - `MATURITY` (class attribute, default `"stable"`): `"experimental"` when the backend is tested against a fake of a network service rather than the service itself. `tether backends` prints it; `add` mentions it. - `LOCAL_PATH_KEYS` (class attribute): locator keys that may hold a local filesystem path, after any of `LOCAL_PATH_PREFIXES` (DuckLake's `ducklake:`, `sqlite:`, ...). At `add`/`import` the engine expands a leading `~` and resolves a relative path against the caller's directory, so a committed locator means one path from every directory and clone. - `branch_scope(locator) -> str` and `ref_namespace(locator) -> str` when the native resource that owns branches, or the one whose pins `list_pins` returns, is wider than one object -- a Neon project, an Iceberg table. The engine forks one branch per scope under a bookmark (members `share` it) and collects `gc` references per namespace. Both default to the canonical identity. - `state_addressable(locator, state) -> bool` (default `True`) when an Addressable backend can produce a state that nonetheless has no coordinate to reopen it by (a `file` object in a bucket without versioning has no `version_id`). `commit` records such a state `recoverable = false`. - `check_quiescence(locator, working_ref)` for `NEEDS_QUIESCENCE` backends; raise `BackendError` if writers are active. - `listing(locator, state) -> str | None` and `diff(locator, a, b, *, listings) -> ObjectDiff` for `DIFF` (below). - `history(locator, ref, limit) -> list[HistoryEntry]` for `HISTORY`: the system's native history newest first (`id`, ISO `when`, `message`, `refs`). Each `id` must be accepted as the locator's `at` field, and `fingerprint` must honor `at` (via `base_at(locator)`) when no working ref is given, so users can register an object at any listed state. Raise `BackendError` (or `CapabilityError` for unsupported operations) with `kind=self.kind`; the engine aggregates per-object failures into `MultiObjectError`. Import optional libraries *inside* methods, so the module imports without them. Decorate the class with `@wrap_library_errors` and give it a `_library_errors()` staticmethod returning the library's exception types, which every protocol method then re-raises as `BackendError`. The decorator wraps only methods *defined on the class*, so re-declare an inherited one. `validate_locator` runs the checks that are cheap at `add` time, so a refusal reaches the manifest's author, not the first reader of a clone. ## Capabilities decide behavior Declare only what the system truly supports; the engine degrades explicitly. | Capability | The engine will... | | --- | --- | | `FINGERPRINT` | `fingerprint` (required) | | `ADDRESSABLE` | let `open(state)` read a recorded state without a pin | | `PIN` | call `pin` on commit, `list_pins`/`unpin` in `gc`, `verify` with the pin. Removed per object by `policy.pin = "record"`, in which case commit records the state instead | | `FORK` | call `fork` in `new` (from the pin, or from the recorded state without one) and return writable handles from `open`; `list_working_refs`/`delete_working_ref` in `gc --prune-bookmarks` | | `CHEAP_FINGERPRINT` | (informational) metadata-only reads | | `RETENTION_BOUND` | (informational) surface that recoverability expires | | `NEEDS_QUIESCENCE` | call `check_quiescence` before pinning | | `ATOMIC_REF` | (informational) pins are create-if-absent | | `CONDITIONAL_REF` | trust that `fork`, and `promote` / `merge` where declared, honour `expected` in one atomic step. Declare it only then; `effective_capabilities` can declare it per object (Icechunk: on `s3://` only). Conformance runs 8 racing forks and fails a backend that declares it and ignores `expected`. | | `DIFF` | call `diff` for `diff --content`, `listing` at commit | | `HISTORY` | call `history` for `tether log` and `add --pick` | | `BRANCH_IS_STORAGE` | never delete a stray working branch in `gc --prune-bookmarks` without `--force-prune` (deleting reclaims the data, as in Neon) | | `PROMOTE` | call `promote` in `tether promote` when the base is unchanged since the fork point | | `MERGE` | call `merge` in `tether promote` when the base moved (or `--strategy merge`) | | `CREATE` | *(experimental)* call `create` for `add --create` / `Repo.create`; `owner`, `is_ref_empty`, and `delete_store` in `gc --delete-stores` for a created store nothing references any more (`tether.experimental.lifecycle`) | ## Content diffs and listings `diff(locator, a, b, *, listings)` returns an `ObjectDiff`: a `unit` naming what is counted (files, tables, arrays, ...), exact `added`/`removed`/ `modified` counts, and entries (capped at 2000; use `out.add(path, change, detail)`). Read metadata only; never data. If your state is a digest that cannot be diffed on its own, also implement `listing(locator, state)`, returning a detailed text description (JSON lines work well). The engine commits it under `.tether/listings/` and hands both sides back as `listings=(text_a, text_b)`. Degrade to a summary line when either is `None`. ## Experimental and graduation Backends driven only through fakes of their service (Neon, Dolt, DuckLake, Iceberg) and the registry layer live under `tether.experimental`: ``` tether/experimental/backends/{neon,dolt,ducklake,iceberg}.py tether/experimental/registry/{export,registry}.py ``` **What is stable.** Kind names (`tether add --kind neon`), extras (`tether-vcs[neon]`), the CLI commands, the `Repo` methods (`repo.export()`, `plan_import`, `apply_import`, `import_objects`), and the `tether.` re-exports (`ExportBundle`, `ImportSpec`, `ImportReport`, `PublishReport`, `build_bundle`, `specs_from_rows`). These do not change when something graduates. Module paths under `tether.experimental` are *not* API, and the alpha-era paths (`tether.backends.neon`, `tether.export`, `tether.registry`) are gone. **It is a real boundary.** `import tether` and `import tether.cli` load nothing under `tether.experimental.registry`. The `Repo` methods import it on first call, the `tether.` names resolve lazily, and `tether.experimental.cli.register(app)` attaches the three CLI commands. **What "experimental" means.** The backend passes the conformance suite and its engine lifecycle against a fake, not the service. `tether backends` reports `experimental`, and `tether add` prints a note. What only a live run can confirm (rate limits, eventual consistency, what a protected branch costs) remains to be seen. **Graduation is a file move.** `build_backend` resolves a kind through `_BUILTIN_MODULES`, and the class's `MATURITY` drives the label. Move the file to `tether/backends/`, point its `_BUILTIN_MODULES` entry there, set `MATURITY = "stable"`, and update the table in [Backends](09-backends.qmd). Nothing users type or import changes. The registry graduates the same way, keeping the `tether` re-exports. `ROADMAP.md` has each backend's call. ## Conformance suite `tether.testing.run_conformance` runs the tier-appropriate checks against any backend. Provide a harness that can create a fresh system and mutate it: ```python from tether.backends import ObjectBackend from tether.manifest import Locator from tether.testing import run_conformance class MyHarness: def __init__(self) -> None: self.backend: ObjectBackend = MyBackend() self._n = 0 def new_object(self) -> Locator: ... # create an empty system with a `main` branch; return its locator def mutate(self, locator: Locator, working_ref: str | None) -> None: self._n += 1 ... # write something new at working_ref (or the base branch) def fresh_locator(self) -> Locator: # CREATE backends only ... # a locator where no store exists yet def test_conformance() -> None: run_conformance(MyHarness()) ``` What the suite checks, per capability: - **Always:** fingerprint stability and change, identity and pin-id determinism, and unpin. - **`DIFF`:** an empty self-diff, and a non-empty one after a mutation. - **`HISTORY`:** the newest entry is the current state and works as `at`, an older entry is another state, and `limit` bounds the result. - **`ADDRESSABLE`:** open by state, an older recorded state included. - **`PIN`:** create, list, verify shallow and deep, idempotency, and protection against later drift of the working ref. - **`FORK`:** a fresh fork equals the pinned state, and writes to it leave the base alone. A stale `expected`, or `ABSENT` onto an existing branch, raises `RefMovedError` and moves nothing. - **`CONDITIONAL_REF`:** of 8 threads forking with one `expected`, exactly one moves the branch; the backend must take `expected`. - **`PROMOTE` and `MERGE`:** the base follows the fork, a second promote is a no-op, a base that moved is refused, two diverged sides merge, and a stale `expected` is refused. `ancestor_of` answers in the right direction, or `None`. - **`CREATE`:** the store lifecycle at a `fresh_locator()`, from `create` through `owner`, `is_ref_empty` (with and without `ignoring`) and a second `create` refused, to `delete_store` and a `create` that works again. A harness may set `capabilities` to exercise one tier of a multi-tier backend. `tether.backends.memory` is the executable specification the suite was written against. `tether.backends.lance` is a Forkable backend that had to bend the mapping (branch-scoped versions, undeletable tagged branches) without breaking the contract. ## VCS adapters Three members beyond reading and committing files. `history_digest()` digests every visible commit id, so a commit made in another workspace stales a gc plan. `shared_dir()` names the directory every checkout of the repository shares (git's common dir, jj's repo dir), where the repository lock and the pinned, created and touched indexes live. `tracked(paths)` says which of tether's per-checkout files the VCS tracks, so `find` can refuse a clone that ships them. Backends never touch the VCS. If you need to support another version-control system for the *manifests*, implement `tether.vcs.VcsAdapter` (resolve, read and list files at a revision, stream history, commit, new) and extend `detect_vcs`. The jj and git adapters read history through one `git cat-file --batch` process (`GitObjectReader`); a non-git store should aim for the same batching to keep `gc` and `verify --all-history` fast. ## Reference ### Caveats and Performance What to know before relying on tether in a pipeline: the limits of the model, the limits of each backend, and what the commands cost. ## Limits of the model ### No cross-system atomicity A commit records each object's state within a capture window; point-consistency requires quiesced writers. The Neon quiescence check helps ([Neon](#neon)), but nothing coordinates two systems. `promote` lands system by system, and a failure part-way leaves the earlier ones landed ([Landing it](04-branching-and-writing.qmd#landing-it-tether-promote)). Promotion is per system too: a system with no merge refuses a diverged base. ### Concurrent writers on one branch Two writers on one store branch (a `--shared` bookmark, two clones on one bookmark) are ordered by a compare-and-swap only where the backend has one: git, and Icechunk on `s3://`. Icechunk on local storage and Lance check the head just before moving it, which leaves a window ([conditional moves](07-cli.qmd#conditional-moves)). Keep such writers on one checkout, or give each its own bookmark. ### Native handles do not follow `new` Every `open` follows the bookmark the checkout is on now, but a handle already open keeps writing to the branch it was opened on. A notebook that opened a handle before another process ran `tether new -b` writes to the old branch. Open the object again after a `new`. ### Icechunk never reuses a deleted tag name A `tether.*` tag deleted by hand, or released by a `gc` you then regret, cannot be recreated under its name: Icechunk keeps a tombstone. tether keeps the pin id and moves the *ref* on instead. `repair`, or the next commit of that state, creates `tether..2`, then `.3`, and readers resolve a pin through its live generation. So nothing is lost while the store keeps the snapshot. There are a thousand generations per id. ### Pin-then-commit ordering Pins are created before the VCS commit. If anything after them fails (a manifest write, a listing, the VCS commit), `commit` releases the pins it created and puts the manifests back. It never releases a pin an earlier commit made and this one reused. If the process dies outright, its journal entry stays `INCOMPLETE`, and `gc` from any checkout keeps the pins that entry names; re-running the commit finishes it. One thing is never undone: a dataset commit that landed. If the adapter fails *after* writing it, history already names the pins, so the commit stands and the error is reported. Re-run `tether verify` after merging manifests across branches. ### One writer per checkout, one per repository Every writing command takes an advisory lock on the checkout (`.tether/lock`). `commit`, `pull`, `new`, `restore`, `gc`, `drop`, `abandon`, `undo` and `repair`, and any lazy fork a writable `open` performs, also take `tether.lock` in the VCS's shared directory, which every checkout of the repository shares. So a `gc` here cannot race a commit in another workspace, and two checkouts cannot both find a branch absent and fork over each other. A second command waits, then gives up with a message naming the lock (30 s for the checkout, 60 s for the repository). Threads sharing one `Repo` take turns the same way. Separate clones share no lock. ### Windows Windows has no `fcntl`, so nothing can order two processes. Commands that write are refused there with one message, and a default `open` is read-only. `status`, `verify`, `diff`, `log`, `ops` and `gc --dry-run` work. Run the writing commands from a POSIX system, or WSL, until the lock has a Windows implementation. Nothing has run on Windows in CI yet. ### Data history costs storage Every pin holds bytes in its system until the commit that names it is dropped and `gc` releases it. `--pin record` skips the native ref and forks from the recorded state, at the price of retention-bound recoverability. Most store writes can be previewed as [plans](05-reclaiming-storage.qmd#preview-before-you-write-plans). ### A clone is untrusted input Nothing committed may choose an executable, an endpoint, SQL, or where a secret is read from. What the boundary does *not* cover: a manifest's locator still names the store tether reads and writes, so a hostile clone can point an object at a bucket you can reach. Review manifests as you would review code ([Security model](08-configuration.qmd#security-model)). ## Per backend ### Neon Experimental: tested against a fake of the Neon API, not a live project. This section is the one home for Neon's caveats. **Lineage.** Pins are child branches, so a working branch cannot be deleted while its pins exist. `gc --prune-bookmarks` plans it as `keep-branch: cannot be deleted` and names the pins, even under `--force-prune`. `gc` only drops leaf pins. `new` onto a *different* pin forks a sibling (`.2`, `.3`, ...) rather than reset a branch with children, while `commit` then `new` reuses the branch, since it sits at the pin. Neon cannot merge or promote a child into `main`: run production writes on the trunk bookmark. **A branch per commit.** Each commit that changes a Neon object adds a branch. Neon plans cap branches per project (10 on Free) or bill those past the allowance. `gc` releases pins no commit references, and `--pin record` takes none at all. Pins are unprotected unless `protected_pins = true` in `secrets.toml`. Neon will not delete a protected branch, and only paid plans have a few, one per pinning commit. Branches *are* the storage (`BRANCH_IS_STORAGE`): `gc --prune-bookmarks` never deletes a Neon working branch holding unpinned data without `--force-prune`. **Control plane.** Answers of 423 (another operation is running), 429 and 503 are retried with backoff for about a minute. `commit` refuses while `pg_stat_activity` shows active writers, unless `--force`. Objects that fingerprint one branch at once share one endpoint, created once. **Fingerprint cost.** The LSN moves without user writes, so it is a volatile address key. Only `commit_xid`, the newest committed transaction id, and the lineage count as change; ids a restarted compute skipped never commit, so they do not count. A snapshot may wake a suspended compute, and creates the read-write endpoint on a branch with none. A plain `status` does not fingerprint. **Content is `{commit_xid, lineage}`.** `branch` in a Neon state is the *lineage*: the object's source branch, when the branch read descends from it. So a fork with no writes compares equal to the pin it was cut from. The price: two sibling branches of one lineage whose newest commits share a transaction id compare equal too. Neon exposes no content hash or history query to tell them apart, which is why `gc` never judges a Neon branch by its state alone, and `promote` is not offered. ### Files on unversioned storage Observed: detectably but not recoverably drifted. `--file versioned` on a versioned bucket makes a single remote object Addressable; local paths and prefixes stay Observed. A local path's identity is its resolved form, so re-pointing a symlink makes a new store: its pins stay safe, but its old listings are not found. ### Iceberg catalog variance S3 Tables disables maintenance when user refs exist. Use `--pin record` there, so forks come from the snapshot id, valid until snapshot expiry. ### Delta has no refs Versions are recorded, not pinned. `VACUUM` (7-day default) and log retention (30 days) bound how long `open -r` works. A recreated table (new metadata id) verifies as drifted, not re-addressed. ### Lance versions are branch-scoped A branch deleted and created again restarts its version numbers, so a state from its earlier life verifies as missing rather than opening the new one. A pinned working branch outlives its bookmark until `gc` drops the tag, since Lance refuses to delete a branch a tag references; `gc` reports it as a failed `delete-branch`. A conditional fork onto an existing branch compares, then deletes and creates it again, so a peer's branch created in that window can be replaced. ### DuckLake attachments A DuckDB-file metadata catalog can be attached only once per process. So tether attaches per operation, and a `DuckLakeHandle` owns its attachment: `close()` it (or use it as a context manager) before the next `status`, and do not hold your own writer connection open in the same process. Postgres, SQLite and MySQL catalogs have no such limit. Snapshots are catalog-wide, and `table` only scopes the handle. Put `CREATE SECRET` statements in `[backends.ducklake] init_sql` rather than credentials in the locator. ### DuckLake and DuckDB versions A DuckDB-file metadata catalog that a newer DuckDB has written can be unreadable to an older one. DuckDB 2.0 creates catalog files in a storage format 1.x cannot read, and its `ducklake` extension moves a 1.x file onto that format at its first write, even while the catalog stays on the DuckLake 1.0 spec. The extension sets the storage version itself, so DuckDB's storage-compatibility settings do not prevent it. From then on tether, like every other reader on DuckDB 1.x, fails to attach the catalog, and its error says to upgrade `duckdb`. Upgrade readers, the environment tether runs in included, before writers. Postgres, SQLite and MySQL catalogs are not DuckDB files, and are unaffected. ### Dolt working sets Uncommitted changes on a branch are in no commit: a dirty branch is reported and refused at `commit`. Credentials come only from the server's `[uris."mysql://host:port"]` entry in `.tether/secrets.toml`, and a server with none gets none. Dirty detection needs a Dolt with the `dirty` column on `dolt_branches`; older servers report clean. ### git remotes With `remote` set, a pin is a pin only once the tag push succeeds: a failed push fails the `pin` and removes the local tag, and `unpin` deletes on the remote first. A committed `remote` must name a configured remote; a URL goes in `.tether/secrets.toml` as `[objects.""] remote`. ## Performance tether is an orchestrator: its cost is the network round trips it fans out, not Python. ### Each command starts a few git or jj processes On local objects, a command's time is mostly git and jj. A `status` starts 6 git or 8 jj processes, at 4-6 ms per git call and 7-15 ms per jj call (jj loads the repository each time, and most calls snapshot the working copy), on top of about 75 ms to start Python. `benchmarks/run.py` in the repository measures each command. ### Fan-out uses threads, not asyncio Every backend SDK tether wraps (obstore, psycopg, pyiceberg, deltalake, lance, duckdb, pymysql, `git`) is synchronous, and the per-object Python work is microseconds. A thread pool gives the same wall-clock concurrency without forcing an async protocol on backend authors. `snapshot`, `verify` and the fork step of `new` all run concurrently. Credentials for one profile or role are resolved once, however many threads ask. ### History walks are batched `gc`, `verify --all-history`, `repair --all-history` and `upgrade` stream every manifest at every commit through one `git cat-file --batch` process (jj repositories are git-backed). Trees and blobs are cached by object id, and each distinct `(system, state, pin)` is verified once. On a repository with 200 commits and 5 objects this takes ~20-40 ms, against 6-12 s with one process per read. History *rewrites* (`upgrade`, `abandon`) read and transform only the commits that touch the dataset directory. ### Opens and refreshes pay for what changed Every `open` re-reads the workspace and the manifests, but a file whose size, mtime, ctime and inode are unchanged costs a `stat`, not a parse. A changed `workspace.toml` also costs one VCS call, to check it is still untracked. ### Fingerprints are metadata-only, except the first local one A remote object is one `HEAD`, a prefix one paged `LIST`, and the etag is the store's content hash. Local files are sha256-hashed, so timestamps do not count as change. The hash is cached under `.tether/cache/` by `(size, mtime_ns, ctime_ns, inode)`; ctime, because an overwrite that restores the mtime cannot restore it. A file changed within two seconds of the last read is read again. The first fingerprint of a tree reads every byte (~1.5-3 GB/s per core, eight files at a time); every later one is an `os.scandir` walk (~4-20 us per file) plus the files whose stat changed. ### Large local trees and the native scanner With `tether-vcs-native` installed, a local directory's walk, hash cache and hashing run in Rust, by the same rules and against the same cache file, so either scanner picks up where the other left off. A warm `status --snapshot` of 100,000 files takes 0.15 s instead of 1.8 s, and of a million 2.1 s instead of 21, in about a quarter of the memory; a first scan is 3-4× faster. It is built for Linux and macOS; elsewhere, and with `TETHER_NATIVE=0`, the Python scanner does the work. Until it is on PyPI, build it from the repository with `uv sync --group native`, which needs a Rust toolchain. ### Content diffs are metadata-only too Each backend reads manifests, logs or catalog tables, never data. So `diff --content` costs about one fingerprint per changed object: two manifest reads for Icechunk and Lance, one log walk for Delta, one catalog query for DuckLake, one prolly-tree diff for Dolt. Two stored file listings of 100k paths diff in about 0.2 s, or 40 ms with the native scanner. ## Core Workflows ### Sharing and CI A dataset is a git or jj repository, so sharing it is `jj git push` or `git push`, and a clone on the other side. This page is about what the clone does *not* carry, and what a teammate or a CI job has to bring before the first command works. ## What a clone carries, and what it does not The repository holds `tether.toml` and one manifest per object: kind, locator, recorded state, pin. So a clone has the whole history, every pin's name, and every object's address. It does not have: | Not in the clone | Why it matters | | --- | --- | | the data | Pins live in the stores. A teammate needs access to every store the manifests name, with their own credentials. | | `.tether/secrets.toml` | Untracked and per checkout. Each clone writes its own (see [Configuration](08-configuration.qmd#trust-boundary-tethersecretstoml)). | | `.tether/workspace.toml` | Which bookmark a checkout works on. A fresh clone is on *no* bookmark until `tether new`. | | `.tether/ops.jsonl` | The operation log is per checkout: `tether undo` there covers only what that checkout did. | | `tether-pinned.jsonl` and the other indexes in `.jj/repo/` or `.git/` | A clone did not create your pins, so its `gc` keeps them as `keep-pin` ([below](#gc-across-clones)). That includes a re-clone of your own dataset. | | local paths | Locators store local paths absolute, as they were on the machine that ran `add` ([below](#local-paths-travel-badly)). | ## Cloning a dataset: a teammate's checklist 1. Install the same tether release and the extras the dataset's kinds need (`pip install "tether-vcs[cli,icechunk,lance]"`); `tether backends` shows what is installed. Install a jj or git that meets the minimum in [Versions and environment](07-cli.qmd#versions-and-environment). 2. Clone: `jj git clone URL` or `git clone URL`. Read `tether.toml` and `.tether/objects/*.toml` before the first command. A clone is untrusted input, and the manifests name the stores every command will contact ([Security model](08-configuration.qmd#security-model)). 3. Write `.tether/secrets.toml` for your identity: a `profile` or `role_arn` per URI prefix, `NEON_API_KEY` or `api_key_env`, a Dolt server entry. `chmod 600` it. 4. `tether status`. The first command mints the checkout's workspace id and takes a snapshot, since there is none yet, so every store is contacted; one you cannot reach is reported as `error` and the rest are listed. The working copy is on no bookmark: reads work, writes do not. 5. `tether verify`: the pins resolve for you too. 6. To write: `tether new main` for the trunk (shared by nature), `tether new -b NAME` for a bookmark of your own, or `tether new NAME` to join one a teammate pushed (`jj git fetch` first). For read-only jobs, steps 4 to 6 reduce to `TETHER_REV=` and `repo.open(key)`. ## Two clones, one bookmark tether sees only the checkouts of *your* clone. A bookmark another live checkout of it works on is refused without `--shared`. A checkout in another clone is invisible, so two clones on one bookmark are `--shared` whether you asked or not. They share no lock either: both write the store branch `tether.ws..`, ordered only by the stores' [conditional moves](07-cli.qmd#conditional-moves). Whoever commits second finds the working copy stale after fetching. `tether new` forks again from the new pins, and refuses to throw away writes it finds on the branch, saying whose they are. A peer's writes are adopted by `tether new --shared` rather than discarded ([Stale working copies](04-branching-and-writing.qmd#stale-working-copies)). One bookmark per person, or the trunk, avoids the question. Bookmarks travel like any jj bookmark or git branch: `jj git push -b NAME` / `git push origin NAME` to share one, `jj git fetch` / `git fetch` to see a teammate's. `tether pull` is not `git pull`: it reads the *stores*, not the remote. ## Local paths travel badly `add` resolves a relative local path against the directory it ran in and stores it absolute, so the committed locator says `/home/ann/data/imaging.icechunk`, not `../data/imaging.icechunk`. On another machine that path is wrong: `status` reports the object as `error`, lists the rest, and exits 1. (Identities use the fully resolved path, so `/p`, `/p/` and `file:///p` are one store: [Conventions](09-backends.qmd#conventions).) Three ways out: - Mount the data at the same absolute path (a shared filesystem, as in [Pinning](03-pinning.qmd#register-the-objects)), or a symlink there. - Move the object to an object store and register it again: `tether remove KEY`, `tether add KEY s3://... --kind icechunk`, `commit`. History keeps the old locator; the new commits use the new one. - A `git` object's `path` must be absolute and outside the dataset's checkout, so a teammate clones the code repository to the same path, or re-registers it. The same holds for `file` directories, `lance` and `delta` local URIs, and a DuckLake catalog's `metadata` and `data_path`. ## `gc` across clones `gc` releases only pins *this clone created*, so a fresh clone cannot release yours by accident: an unreferenced pin it did not create is listed as `keep-pin`. The record lives beside the repository lock, not in history, so re-cloning loses it, and the new clone keeps even your old pins until you pass `--release-foreign`. Fetch every bookmark before running `gc`, so the other clones' commits count as references, and pass `--release-foreign` only for a clone you know is gone. `--keep-bookmark NAME` protects a bookmark that lives only on another machine from `--prune-bookmarks`. `drop` notes when a pushed `NAME@origin` still reaches the commits; the next `jj git push` (or `git push origin --delete NAME`) removes it. The rules: [Reclaiming Storage](05-reclaiming-storage.qmd#pins-this-clone-did-not-create). ## A CI recipe Three commands make CI gates, because each exits 1 when something is wrong ([exit codes](07-cli.qmd#exit-codes-and-errors)): | Command | Catches | When | | --- | --- | --- | | `tether verify --all-history --deep` | a pin that no longer resolves, or a recorded state the system expired | nightly, and on every push | | `tether status --snapshot` | an `immutable` file that changed, an object that cannot be read | nightly | | `tether promote --strategy ff` | a bookmark that cannot land as a fast-forward everywhere | when a bookmark is approved | `--deep` opens each recorded state. Without it a Delta, DuckLake or `--pin record` state reports `unknown`, which is "not ok" and exits 1. `status --snapshot` exits 0 when an upstream moved (`modified`, `behind`), so to fail on that, test its JSON: `tether status --snapshot --json | jq -e 'all(.objects[]; .state == "clean")'`. `promote --dry-run` exits 0 whatever the plan says, so gate on the real command, or on the plan's JSON. Credentials come from the runner's secret store, written into the untracked `.tether/secrets.toml` before the first command (tether does not expand `${...}`; write the values in). A GitHub Actions job: ```yaml jobs: verify: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: { fetch-depth: 0 } # verify --all-history walks every commit - run: pip install "tether-vcs[cli,icechunk,lance]" - name: Credentials for this checkout only run: | mkdir -p .tether && umask 077 cat > .tether/secrets.toml <` (git) | A fork, reset or promote would move a branch another worktree has checked out. | Check out another branch in that worktree, then run the command again. | | `diff compares the working copy with its parent, but ...` | The jj working copy is a merge, with two parents. | `tether diff REV` compares one of them with the working tree. | | `file storage_options: unknown client option ...` | A client option in `secrets.toml` is not one obstore takes, or its value cannot mean what the option needs. | Fix the key or value; any spelling of a known option works. | | `a newer DuckDB has written this DuckDB-file catalog` (ducklake) | A DuckDB newer than the one tether runs with has written the catalog's metadata file, in a storage format older ones cannot read. DuckDB 2.0 does this to a 1.x catalog at its first write. | Upgrade `duckdb` where tether runs ([DuckLake and DuckDB versions](12-caveats-and-performance.qmd#ducklake-and-duckdb-versions)). | | `tether.toml is version 4; this tether expects 5` | A 0.1.0b1-b3 dataset. | `tether upgrade`, then re-sync the other clones; 0.1.0b3 refuses the upgraded dataset. | | `'lakefs': N manifest(s) skipped` (a note or warning from `gc`, `verify`) | History names a backend tether no longer ships. Its pins still count as references. | Nothing; remove the objects in a new commit when convenient. | | `...takes no expected, so its ref moves are not conditional` (a `RuntimeWarning`) | A third-party backend predates conditional moves. | Update the backend ([Writing a Backend](11-extending.qmd#the-protocol)). | ## An interrupted command A store-writing command journals its plan before the first side effect and marks completion afterwards. A process killed in between leaves an entry marked `INCOMPLETE` in `tether ops`: - `tether repair --dry-run` lists what the operation got done, and what was planned. - Running the same command again finishes what is left. `commit` first releases the pins its failed attempt created, unless the VCS commit landed; then the commit stands. - `tether gc` keeps every pin an unfinished operation names, from any checkout of the repository. - `tether undo` refuses it, since what it did is not fully known. The journal: [Operation log](08-configuration.qmd#operation-log). ## Warnings `status` prints These are not errors, and the line says what to do: - `(STALE: key, ...)` in the header: see `StaleWorkingCopyError` above. - `warning: bookmark ... is gone / renamed / moved / left`: the VCS moved the bookmark behind tether's back ([Workspaces and bookmarks](04-branching-and-writing.qmd#workspaces-and-bookmarks)). - `warning: dataset commit ... is no longer in VCS history`: a `jj undo`, `jj abandon` or `git reset` removed a commit tether made. `gc` releases its pins, and `repair` recreates them if the commit comes back ([Undo](04-branching-and-writing.qmd#undo)). - `error` as an object's state: its fingerprint failed, now or at the last snapshot. The reason follows on stderr and `status` exits 1. ## Recovering a lost dataset repo The stores keep tether's refs when the repository with the manifests is gone, such as a clone that was never pushed, or a lost disk. Every pin is a ref `tether..` (a tag, in most stores), and the hash covers the object, its state and the dataset id. Every bookmark has a working branch `tether.ws..`; Lance and Neon add `.2`, `.3` when they cannot reset a branch in place. A new dataset under the old id takes them back: 1. **A fresh repository, the old id, the objects.** In a new jj or git repository, run `tether init --dataset-id ID` with the old id, if you know it (if not, any id: step 2 finds it). Then `tether add` each object with its old locator. Kinds whose locator is a path need no key: `tether add s3://lab/imaging.icechunk --kind icechunk` registers `imaging`. ```bash jj git init lab-data && cd lab-data tether init --dataset-id 0a1b2c3d tether add s3://lab/imaging.icechunk --kind icechunk ``` 2. **`tether recover`** lists tether's refs in each object's store by the dataset id they carry: the pin count, and each bookmark with a working branch (and its generation, when there are several). It marks the id `tether.toml` names, prints what to run next, and writes nothing. Each step is a command, its values quoted for the shell, followed by a `#` note; a step with nothing to run is a note alone. If a store can't be listed, it names that object, suggests nothing, and exits 1: fix access, or leave the object out with `tether recover KEY...`. A run scoped to some keys suggests no dataset id and no `commit`: a commit pins every object under the current id, which then can no longer change, so an id only a store left out holds would be lost. Its last step is `tether recover` without keys. ``` $ tether recover dataset id 0a1b2c3d (tether.toml) imaging [icechunk] 0a1b2c3d (this dataset): 5 pin(s); bookmarks: relabel, sweep next steps: 1. tether commit -m 'Recover main' # pins each object's upstream branch on the trunk (main); a state pinned before reuses its tag 2. tether new -b relabel --adopt -- main && tether commit -m 'Recover relabel' # bookmark relabel, made afresh off the trunk: takes its branches as they are, uncommitted writes included, and pins them 3. tether new -b sweep --adopt -- main && tether commit -m 'Recover sweep' # bookmark sweep, made afresh off the trunk: takes its branches as they are, uncommitted writes included, and pins them ``` If a manifest already names a pin its store lacks (a tag deleted by hand, say), the first step is `tether repair -- KEY...` for those objects: a `commit` does not recreate a pin whose state is unchanged. A run scoped to some keys repairs only the selected ones. 3. **Set the id, if it is not the old one.** When the refs carry another id and nothing is pinned under this dataset's yet, `recover`'s first step is a note with what to put in `tether.toml`: ``` 1. # set this dataset's id in tether.toml (nothing is pinned under 12345678 yet): # [dataset] # id = "0a1b2c3d" ``` The id can change only while nothing is pinned under it ([Dataset id](08-configuration.qmd#dataset-id)). Once something is, `recover` says so, and the way back is a fresh repository with `tether init --dataset-id`. 4. **Commit on the trunk.** `tether commit` pins each object's upstream branch. A state the old dataset pinned gets the same pin id, so its tag is reused and no new tag is made. 5. **Adopt each bookmark, and commit.** `tether new -b NAME --adopt -- main` takes the bookmark's branch as its working ref as it is: no reset, no copy, and writes nobody committed stay. `status` shows them as `modified`, and `tether commit` pins them. An object whose store has no branch for the bookmark forks from its pin, as `new` always does. A bookmark whose name had `/`, `.` or a space is listed as its branch spells it (`feat-x-1a2b3c` for `feat/x`), and either name finds the branch. Where the VCS still has the bookmark, `recover` suggests `tether new --adopt -- feat/x`. Where it does not, the original name cannot be read back from the branch: `recover` suggests `tether new -b feat-x-1a2b3c --adopt -- main`, which takes the branch under the escaped name. Every suggested command ends its options with `--`, since a key or a git branch name may start with `-`. 6. **Copy legacy branches.** Datasets from before bookmarks named working branches per workspace, `tether.ws...`. `recover` lists them under their dataset id as legacy branches of workspace ``, since one may hold the only copy of writes nobody committed. `new --adopt` does not take them, and `restore` refuses the trunk, so each step first moves to a bookmark `recover-`, made off the trunk by the workspace's first step and joined by the rest. An existing bookmark of that name is joined only if it holds nothing but an earlier recovery's commits and registers the objects; otherwise the steps make `recover--2` and leave it alone. Then `tether restore --at REF -- KEY` copies the branch onto the bookmark's branch, and `tether commit` pins it before the next step's `new`, which would reset it otherwise. Objects that share a branch space, such as two Neon databases on one branch, are restored together in one `tether restore --at REF -- KEY1 KEY2`: ``` 2. tether new -b recover-ab12cd34 -- main && tether restore --at tether.ws.0a1b2c3d.ab12cd34.imaging-3f2a1b -- imaging && tether commit -m 'Recover tether.ws.0a1b2c3d.ab12cd34.imaging-3f2a1b' # tether.ws.0a1b2c3d.ab12cd34.imaging-3f2a1b is a legacy branch of workspace ab12cd34 (named before bookmarks) and may hold writes no commit pins: this copies it onto bookmark recover-ab12cd34 and pins it ``` The VCS history is lost for good: the commit messages, and which states were committed together. The pins survive, but nothing says which of them made up one version of the dataset. An old pin that no new commit reuses, such as a bookmark's earlier commits, is named by no manifest and was not created by this clone. `gc` therefore plans it as `keep-pin` until `tether gc --release-foreign`. To keep one as a starting point, `tether restore KEY --at tether..` on a bookmark copies it onto the bookmark's branch. ## Removing tether from a repository There is no `tether uninstall` yet (it is on the roadmap), so the recipe is manual. The point is the refs tether left in the stores: every pin is `tether..` (Icechunk repins add `.2`, `.3`), and every working branch `tether.ws..`, where `` is the `[dataset] id` in `tether.toml`. Nothing else in a store is tether's. 1. **Let tether release what it can.** `tether drop NAME --no-dry-run` for every bookmark but the trunk releases their pins and branches; add `--delete-stores` to also delete the stores `add --create` made on them. Then `tether gc --prune-bookmarks --no-dry-run`, with `--release-foreign` if this is the last clone. A created store that a manifest in history still names (one the trunk committed) is kept, never deleted: remove it by hand in step 3. 2. **Find the stores.** Every commit's manifests name them: `tether export refs.sqlite` puts them in one table (`SELECT DISTINCT kind, uri FROM objects`), or read `.tether/objects/*.toml` at each commit. 3. **Delete the remaining refs, per store.** History still names the trunk's pins, so they go by hand: | Kind | List | Delete | | --- | --- | --- | | `icechunk` | `repo.list_tags()`, `repo.list_branches()` | `repo.delete_tag(name)`, `repo.delete_branch(name)` | | `lance` | `ds.tags.list()`, `ds.branches.list()` | `ds.tags.delete(name)` first, then `ds.branches.delete(name)` (a branch a tag references cannot be deleted) | | `git` | `git tag -l 'tether.*'`, `git branch --list 'tether.ws.*'` | `git tag -d NAME`; `git push REMOTE --delete NAME` where pins were pushed; `git branch -D NAME` | | `neon` | the project's branch list (console or API), names `tether.*` | the pin branches first (children of the working branches; unprotect the protected ones), then the `tether.ws.*` branches | | `iceberg` | `table.refs()` | `table.manage_snapshots().remove_tag(name)` / `.remove_branch(name)` | | `dolt` | `SELECT tag_name FROM dolt_tags`, `SELECT name FROM dolt_branches` | `CALL DOLT_TAG('-d', name)`, `CALL DOLT_BRANCH('-D', name)` | | `delta`, `ducklake`, `file` | -- | nothing: these hold no refs. A `file` directory `add --create` made has a `.tether-owner` marker to remove | Delete only names carrying *this* dataset's id. Releasing a ref makes the data eligible for the system's own expiry (`expire_snapshots`, `cleanup_old_versions`); run that afterwards to reclaim the bytes. 4. **Remove the files.** Delete `tether.toml` and `.tether/` from the working tree and commit. Delete `tether.lock`, `tether-pinned.jsonl`, `tether-created.jsonl` and `tether-touched.jsonl` from the VCS's shared directory (`.jj/repo/` or `.git/`). Every other checkout deletes its own `.tether/`. Old commits keep their manifests as text; rewriting history to remove them is optional, and the VCS's job.