Repository navigation
tether 0.1.0b1
Pre-releaseThe alpha-exit release, and the first beta. Two security fixes, the engine
bugs an external review found, a hardened backend contract, one migration
for every alpha format (removed at 0.1.0 -- see Deprecated), an
experimental package that is an import boundary with a seamless graduation
path, a narrower undo that reverses only what an operation created,
tether.repo as a package of one module per command family, and -- as an
experimental feature -- a store lifecycle: add --create makes a store
tether owns and gc --delete-stores reclaims it. What still has to run
against real services, and what graduates or goes before 0.1.0, is in
ROADMAP.md.
Upgrade with tether upgrade; move [vcs] git_path/jj_path,
[backends.neon], [backends.ducklake] init_sql, [backends.lakefs], and
any endpoint or credential option out of tether.toml into
.tether/secrets.toml.
Security
- A cloned dataset is untrusted input. The committed
tether.tomlcould
choose the executables tether runs ([vcs] git_path/jj_path), the
endpoint credentials are sent to ([backends.neon] api_url,
storage_options.endpoint, lakeFS client kwargs), SQL to run
([backends.ducklake] init_sql), and which environment variable is sent as
a password ([backends.dolt] password_env). Backends now declare
SAFE_CONFIG_KEYS; a committed key outside the allowlist -- or an
endpoint-/credential-shaped key inside an allowed option table -- is
refused with a message saying where it belongs. Those settings live in the
new untracked.tether/secrets.toml([vcs],[backends.<kind>]) or the
environment (TETHER_GIT,TETHER_JJ). An Iceberg locator'scatalog
table is screened the same way. - git argument injection. Manifest and state values (
ref,at,
remote, a pin's ref, asha) reached git positionally without
--end-of-options, soat = "--output=FILE"madegit logwrite FILE.
Every git call now passes--end-of-optionsbefore its positionals, a
shamust be hex, and aref/at/remote/pathbeginning with-is
refused atadd(ObjectBackend.validate_locator) and at use.
Fixed
promotemoved the trunk bookmark backwards or sideways when the trunk had
gained commits the bookmark lacked, dropping them offmain. A full
promotion is refused at plan unless the trunk is an ancestor of the
bookmark's commit (merge or rebase the manifests first, or name keys); at
apply the trunk is never moved backwards (PromoteReport.trunk_held).newon an existing bookmark (reuse) overwrote the branch's fork point
with its own head, so the nextpromotesaw a spurious "base moved" and
merged (or refused) where a fast-forward was right. A kept branch keeps its
fork point, andpromoteasks the store's own history first
(ancestor_of), using the recorded fork point only where the backend has
no DAG.- Neon put the branch name in the content state, so an untouched fork read
as modified,commitpinned a child of the working branch for no data
change, andnewon the bookmark demanded--discard. The state's
branchis now the lineage (the object's source branch when the fork
descends from it) and the timeline actually read is the volatile
timeline, so a fork with no writes has the pin's content -- the Lance
pattern. Neon runs the shared conformance suite and a fullRepolifecycle
against the API fake; the suite's stability and fork checks compare
content states (up toVOLATILE_KEYS). statusspawned two VCS processes per commit in the op log to detect
drift;vcs_driftnow asks once (VcsAdapter.alive_commits).- Opening a dataset rewrote
.tether/.gitignore;Repo.findnow writes it
only when an untracked file that exists is not yet ignored (so an older
dataset's newsecrets.tomlnever reaches a commit).initandupgrade
write the full list. - The checkout lock failed outright when another command held it; it now
waits up toRepo.LOCK_TIMEOUT(30 s) like the repository lock, and the
no-fcntlbranch is re-entrant. - A saved
gcplan under jj went stale after any snapshot: the digest now
coversall() ~ working_copies()and binds to the working copy's parent. name.2working refs (a Neon or Lance sibling of a branch that could not be
reset) are parsed back to their bookmark, and an 8-hex bookmark with a
suffix is no longer mistaken for a legacy workspace id;new -brefuses a
bookmark name ending in.<number>.
Deprecated
- The alpha upgrade path.
tether.upgrade(the one composed migration
from any 0.1.0aN format, its history rewriters, and the legacy working-ref
parsing) ships with the 0.1.0 betas and is removed at 0.1.0. After that, a
dataset at an alpha version fails at open with a message naming the last
beta: installtether-vcs==<last beta>, runtether upgrade, reinstall.
Repo.plan_upgrade/apply_upgrade/upgradeare thin delegates that
import the package on first use;repo.pyandvcs.pyread without it.
tether.migrationsis nowtether.upgrade.migrations;UpgradeReport
still imports fromtether.
Changed
tether.repois a package. The 5,500-linerepo.pyis split by
command family --_core(state, locks, construction, backends, the op
log, plan verification),_objects(add/remove, set, pull, snapshot and
status, open, history, verify, diff),_commit,_fork(new, restore),
_promote,_gc(gc, forget-workspace, abandon),_undo(undo, repair),
and_reports(the result dataclasses) -- each a mixin overRepoCore,
assembled into the same publicRepo. A pure move: the same 128 methods,
the sametether.repoexports. Along the way the four workspace-walking
loops became one_iter_live_workspaces(), andplan_promotereads every
object's base and working branch in one fan-out instead of two round
trips per object.- Round-2 review corrections: committed option tables (
storage_options,
catalog) are screened by a per-backendSAFE_OPTION_KEYSallowlist
rather than a substring blocklist, so an option tether has never named is
refused by name;Repo.backend_forreads the class contract via
backend_class()instead of building a probe instance;tether open
prints a Neon connection URL with the password redacted unless
--with-password(never under--json); Icechunk, Delta, and DuckLake
implementvalidate_locator(URI scheme, numericat); Neon takes one
branch listing per operation and documents thenext_xidcollision
between sibling branches;PromoteReport.trunk_movedhas its docstring
back. - Plan preconditions. What a plan saw is recorded as typed
Plan.preconditions(manifest_hash,workspace_id,vcs_head,
history_digest,config_version,ref_absent,ref_head,base_state,
pin_state,no_new_holders) instead of being re-implemented inline per
command; oneRepo._verify_plan()runs them before anyapply_*acts.
Saved plans are format 2; a format-1 plan (before 0.1.0b1) is refused with
"re-run the plan". Same semantics for every command, one place to audit
the drift contract. - Hygiene:
forget-workspacelost its deaddelete-branch/keep-branch
actions and the--force-prunecompatibility flag (branches belong to
bookmarks;gc --prune-bookmarksjudges them);Handle.keyis documented
as a display label, not an identity; the docs describe staleness as the
code enforces it (detected per object, refused workspace-wide) and drop
the stale--prune-workspaces/--write directreferences; the test
suite findsjjonPATH(orTETHER_TEST_BIN) instead of a hard-coded
path. undoreverses what an operation created, and reports the rest.
undo new/fork/restoredelete the branches the op created and restore
workspace.tomland the VCS position; a branch the op reset is no longer
re-pointed to a recorded head (the store may not allow it and the head to
choose is yours) -- it is reported with its old head and the tool that
moves it (restore KEY --from REV,new --discard).undo gcrestores
forgotten working refs and listings but no longer recreates deleted
branches (repairdoes, from the manifests).undo --toand
Repo.undo_toare gone: several slips are severalundos, newest first.tether.experimental. The backends tested only against fakes (Neon,
lakeFS, Dolt, DuckLake, Iceberg) moved totether.experimental.backends,
and the registry layer (export,publish,import) to
tether.experimental.registry. Nothing users type or import changes: kind
names, extras, CLI commands, theRepomethods, and the
tether.ExportBundle/ImportSpec/ImportReport/PublishReport/
build_bundle/specs_from_rowsre-exports are the stable surface.
It is an import boundary:Repo.export/plan_import/apply_import/
import_objectsare thin delegates totether.experimental.registry.ops
imported on first call, thetether.<Symbol>names resolve lazily, and
the CLI commands are attached fromtether.experimental.cli, so
import tetherloads none of it. The alpha-era module paths
(tether.backends.{neon,lakefs,dolt,ducklake,iceberg},tether.export,
tether.registry) are removed without a deprecation window.
export/publish/importprint the experimental noteaddalready
printed for experimental kinds. Graduating a backend is a file move plus
MATURITY = "stable"(documented in Extending).- One migration.
tether upgradebrings any alpha dataset to the
current version in a single step whose parts run on what the dataset shows
(old-format pins,write =or mtime file states, misplaced manifests or
relative locators), not on its recorded version. One version write at the
end, one VCS commit; store renames still fail closed before any history
rewrite. The plan records itsparts. - Backend contract. Icechunk, Iceberg, Delta, Dolt, Neon,
file, and
DuckLake re-raise their library's exceptions asBackendError
(wrap_library_errors), so a network blip or a missing ref is a refusal at
the engine'sexcept TetherErrorsites, not a traceback from inside a
client. TheObjectBackendprotocol's ownfingerprint/pin/fork/open
bodies raiseNotImplementedErrorinstead of returningNone. - Icechunk
pinresolves the snapshot before spending a tag name (a missing
snapshot no longer burns generations);forkleaves a branch already at
the source alone and wrapsreset_brancherrors. Gitforkskips
branch -fwhen the branch is at the source. Deltaverify --deepreads
the version's data files (a vacuumed version ismissing).filerefuses
to fingerprint a prefix whose objects report no ETag instead of digesting
names alone; its listing cache is guarded by the hash-cache lock. Neon pins
are protected by default withprotected_pins = falsefor the free tier,
delete_working_refre-lists to confirm, andNeonHandlekeeps the
password out ofrepr(redacted_url). Doltbranch_head/resolveno
longer read a lost connection as "no such ref". - Conformance suite: the "already at source is left alone" half of the fork
contract,pin(same id, other state)raises, andopen/unpinof a
missing ref raiseBackendError(never a library exception). Iceberg is
experimentaluntil it runs against a real catalog.
Added
.tether/secrets.tomlalso carries per-URI-prefix and per-object
credentials:profile,role_arn(resolved throughboto3into explicit
keys),endpoint_url,region, or literal keys. Icechunk passes them to
s3_storageinstead offrom_env;file, Delta, and Lance take them as
storage options. Resolution: object entry, longest URI prefix, kind
section, then the environment -- an object with no entry behaves as before.
tether warns when the file is readable by other users and never prints its
contents.ObjectBackend.configure_secrets/secrets_forare the hooks.
Experimental
The store lifecycle below lives in tether.experimental.lifecycle: the one
operation with no repair path, not yet run against real resources (an S3
prefix, a second clone). add --create and gc --delete-stores say so.
Graduation criteria are in the module docstring.
- Store lifecycle: create and reclaim.
tether add KEY LOCATOR --kind KIND --create(Repo.add(..., create=True)) has the backend make an empty store,
write an owner marker in the store naming the dataset, and record it in a
repository-wide untracked index (tether-created.jsonlnext to the
repository lock in.jj/repo/or.git/); the manifest carries
origin = "created"andstatusshows(created). On a non-trunk bookmark
the new store's working branch is forked at once, so no secondnewis
needed before the first write.Repo.create(key, kind, locator)is
add(create=True)plusopen-- a throwaway environment's whole setup in
one line;openitself is unchanged.add --createjournals before it
writes to the store, like every other store-writing command.undo add
removes the store--createmade while it is still empty. gc --delete-stores(opt-in: a deleted store has norepair, andgc
only knows the history this clone has fetched) reclaims created stores
after the pin and branch decisions. A store is planned fordelete-store
-- last in the plan, behind astore_emptyprecondition re-checked right
before the delete -- when its marker names this dataset, no manifest in
history or in any live checkout's working tree references it, every
same-dataset branch in it belongs to a bookmark this clone can account for
(the one it was created on, a live one, or one anewin any live checkout
made -- a branch of a bookmark this clone never had may be another actor's,
and the plan then touches nothing in the store), its own pins and branches
are released by the plan (branches under the--prune-bookmarksrules),
and the backend confirms nothing else remains. Anything else is a
keep-storenaming what remains; a store already gone is aforget-store.
GcReport.deleted_stores/kept_stores/forgotten_storesreport it;
undo gcreports a deleted store as irreversible. A clone's manifest never
authorizes a delete: only the marker and the index in your own repository
do.gc --store KIND=LOCATOR(plan_gc(stores=[...])) names a created
store to consider when the creator's clone -- and its index -- is gone.gclooks inside the stores this clone has touched: every store a
checkout forks a branch in or pins is recorded intether-touched.jsonl
beside the created index, and a plaingcreleases this dataset's dead
refs there once no manifest names the store any more (an abandoned bookmark
took the manifest with it). Before, those refs stayed forever and kept the
store's creator from ever reclaiming it. An entry is dropped
(forget-touched) once nothing of the dataset's is left.memoryaccepts
the positionalurilocator like other kinds.- Backend contract:
Capability.CREATEwithcreate(locator, *, owner),
owner(locator),is_ref_empty(locator, *, ignoring=())(Nonemeans
keep; a working area must hold no uncommitted content), and
delete_store(locator)(re-checks both, removes only the store's own
layout); implemented formemory,icechunk(repository metadata marker;
refuses a non-empty path or prefix; deletes only Icechunk's top-level names,
1.x and 2.x layouts),git(initwith one fixed empty root commit shared by
every created repository;git config tether.owner; a dirty working tree or
an amended root is not empty), andfile
(a local directory with a.tether-ownermarker the directory walk skips).
Lance, Delta, and Iceberg need a schema and do not declare it. The
conformance suite walks the lifecycle for harnesses that provide
fresh_locator().