# 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](getting-started.md) 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](../reference/testing.ObjectDiff.md#tether.testing.ObjectDiff.add) writes manifests only; [status](../reference/backends.VerifyReport.md#tether.backends.VerifyReport.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](branching-and-writing.md#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](../reference/CommitResult.md#tether.CommitResult.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](../reference/handles.IcebergHandle.md#tether.handles.IcebergHandle.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](../reference/Plan.digest.md#tether.Plan.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](../reference/SetReport.md#tether.SetReport.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](../reference/vcs.VcsAdapter.md#tether.vcs.VcsAdapter.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](../reference/backends.VerifyReport.md#tether.backends.VerifyReport.status) labels it [error](../reference/ObjectStatus.md#tether.ObjectStatus.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](../reference/testing.ObjectDiff.md#tether.testing.ObjectDiff.add) it again, commit), or something wrote where it should not have. Other drift is benign and exits 0: a moved branch shows [modified](../reference/testing.ObjectDiff.md#tether.testing.ObjectDiff.modified) and is pinned by the next commit, and on the trunk a branch-less object past its pin shows [behind](../reference/ObjectStatus.md#tether.ObjectStatus.behind) until `pull`.


# Verify

Pins are references *inside other systems*, and those systems have their own operators. [verify](../reference/ObjectStatus.md#tether.ObjectStatus.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](../reference/backends.VerifyReport.md#tether.backends.VerifyReport.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](../reference/ObjectStatus.md#tether.ObjectStatus.verify) exits 1 if anything is not [ok](../reference/backends.VerifyReport.md#tether.backends.VerifyReport.ok), `unknown` included, so the CI form is `verify --all-history --deep` ([A CI recipe](sharing-and-ci.md#a-ci-recipe)).

A pin is never taken on trust. Before `open --rev`, [new](../reference/vcs.VcsAdapter.md#tether.vcs.VcsAdapter.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](../reference/PinDriftError.md#tether.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](../reference/ObjectStatus.md#tether.ObjectStatus.verify) finding, not a `gc` one. [Reclaiming Storage](reclaiming-storage.md) covers the rest.


# Next

[Branching and Writing](branching-and-writing.md): change these objects on a bookmark, then land or discard the result.
