hav
Concepts

Sealed crews

A crew seals whole lines of work inside a shared repository: buoys under private-<slug>/ and every knot born on them store encrypted, readable and extendable only by the crew's members. Non-members see that the crew exists (its slug, member public keys, and row counts) and nothing else: no buoy names, no descriptions, no snaps, no ancestry.

All cryptography is client-side. A hub (or harbor) holds ciphertext it cannot read, and the primitives are boring on purpose: X25519, XChaCha20-Poly1305, and BLAKE3, composed conservatively.

Identity: one key per person

hav key gen     # once, per person; creates ~/.config/hav/identity
hav key         # prints your public key, pk-…

Private keys never move, and membership is managed with public keys only. Back the keyfile up. It is the only thing that unwraps sealed crews, and losing it permanently loses every sealed thing.

To replace a key, whether leaked or just aging, hav key rotate mints a fresh primary while the old key stays readable. This two-key overlap means there is no window where you are locked out. Add the new pk to your crews with the ordinary --add, remove the old pk, then run hav key rotate --finish to drop the old key (it asks first). A stolen key follows the same flow, but removed members keep what they already read, so rotate the real-world secrets the thief saw (see below).

Crews and membership

hav crew cve-2026-1234 --name "the parser fix"   # create (slug immutable, name sealed)
hav crew cve-2026-1234 --add pk-…                # add a member
hav crew cve-2026-1234 --remove pk-…             # remove (rotates the key)
hav crew                                          # list
  • The slug is public and reserved forever, and the display name is sealed, so members see it while others see the slug and counts. Slugs are DNS-label shaped: lowercase letters, digits, and hyphens in between, starting with a letter and ending with a letter or digit (use and none are reserved).
  • The crew secret is a generation key, wrapped once per member to their public key. Adding a member wraps the current generation to them, and one wrap unlocks all history. Removing one mints a fresh generation for everyone who stays and chains the old one beneath it. Nothing is re-encrypted, and ids don't change.
  • A crew answers only to its own members. Creation wraps the key to its creator alone, and hav never forces another key onto a crew, whoever's repo it lives in. A crew you create in your clone of someone else's repo is readable by you and the members you add. Any member can add or remove any other, and removal, the creator included, is the ordinary rotation with no special-cased identity. Removing the last member is allowed and said out loud: the crew's sealed history becomes permanently unreadable (hav undo restores it while the op is retained).
  • Multi-device and CI access are just membership. Register each machine's public key, such as a laptop or a build bot, as its own member. hav key rotate swaps one machine's key. Beyond that, hav ships no rotation schedules, escrow, or recovery in v1, since those are hosting-product services. An org that wants a mandatory recovery recipient adds one as hosting policy.
  • Lazy revocation is physics. Removed members keep what they already read. The CLI says so, and you should rotate real-world secrets they saw.

Deleting a crew

hav crew -d cve-2026-1234        # asks y/N, naming what dies

Removes the crew from this copy, meaning config, wraps, key chain, and its sealed buoys, in one undoable op. Membership is not required: whoever holds a copy is sovereign over it, and a crew someone else made in the repo you cloned is yours to drop. The crew's sealed history becomes unreadable here (hav undo restores it while the op is retained, and hav sweep frees the ciphertext once nothing retained reaches it). Snaps are immutable, so entries carrying the crew's variants stay and render as opaque stubs, exactly what a non-member always saw.

Deletion refuses while any workspace has the crew active (hav crew use --none first) or sits on one of its knots (hav ws -d or tie first). It never juggles disk state behind a workspace's back.

It is local, twice over: remotes are untouched, and a later pull that carries the crew's content, or wraps your key, brings the config back. The slug is immediately reusable in this copy, but the recreation is a brand-new crew with a fresh identity, so copies that knew the old one will never accept the new one under that slug (see Remotes and sealed history).

Working sealed

Nothing about the daily flow changes. Create a buoy under the crew's prefix, and work tracks it as usual:

hav buoy private-cve-2026-1234/fix --at main
hav ws new ../fix --on private-cve-2026-1234/fix
# edit, describe, tie: everything stores sealed automatically

Knots inherit their crew from the buoy they're born on. The store routes transparently: members read and write sealed rows through the same commands, and non-members get not a member of crew '<slug>'.

Crew boundaries are enforced everywhere: a public buoy cannot name a sealed knot, one crew's buoy cannot name another crew's knot, squash refuses to cross a seal boundary, and a tie mixing two crews is refused.

Sealing individual files: variant sets

Whole buoys are the right unit for a private line of work. A team's .env needs something different: encrypted content inside an otherwise-public tree. Every non-directory path in a snap carries a variant set: at most one public variant (ordinary plaintext, visible to everyone), plus any number of sealed variants, at most one per crew, so ten crews can each have their own .env at the same path. No crew owns a path, and a plain public file is the degenerate set.

Your edits are routed by a workspace setting, the active crew:

hav crew use ops-team        # materialize ops-team's variants; route edits to them
hav crew use --none       # back to the public view
hav seal .env             # the file on disk becomes ops-team's sealed variant
hav seal .env --keep-public   # …keeping the current public variant alongside

Materialization, per path, is a two-step fallback: the active crew's variant if it has one, else the public variant, else nothing, leaving the path absent on disk. Capture routes by what materialized, so you edit what you see:

  • Active crew, path has sealed variants. Your edits create or update that crew's variant, and deleting the file deletes only that variant. (With no variant of yours there, deletion has nothing to target and everything carries.) Every other variant, the public one included, rides through unchanged, and bytes identical to another crew's variant are recognized as its leftover materialization and never resealed under yours. A plain public path stays public even with a crew active: activating a crew does not fork the tree, and a fresh sealed variant is created only by the explicit hav seal.
  • Active crew locked (your keys rotated away while it was active). Its variant paths are left strictly untouched, never published and never deleted. st says so, and hav crew use --none returns to the public view. Any lingering secret backs up as <name>.hav-saved, which capture always ignores.
  • No active crew. You see and edit the public variants. A path holding only sealed variants is absent on disk, a hand-kept copy you park there is left strictly alone, and capture with the file absent carries the variants through. Absence deletes only what actually materialized for you.
  • The publish gate is the one guardrail. The first capture that would create a public variant at a sealed-only path says so out loud and asks (.env has sealed variants (ops-team) and no public one — capture this file as its PUBLIC variant? [y/N]). Declining skips the path, storing nothing and dropping nothing. Updating an existing public variant never asks, since that is an ordinary edit of visible content.
  • The content travels with public history. Push, pull, and clone carry the sealed variants and their ciphertext keylessly, and a harbor hands them to every puller, anonymous ones included. A fresh clone (and a fresh ws new) starts on the public view, and hav crew use <slug> is the explicit act that materializes secrets.

Variants inside a sealed buoy

The two seals have different lifetimes: a private buoy is temporarily secret (it exists to be revealed), a sealed variant is permanently secret (only hav unseal ever publishes it). They compose in one direction — a crew's own variants work inside its own sealed buoy exactly as they do anywhere else. On a private-ops-team/ knot, hav crew use ops-team materializes ops-team's .env, edits route to it, and hav seal can mint new variants: one key opens both layers. When the buoy is revealed, the knot's tree re-homes as plaintext and the crew's variants ride through still sealed — the feature goes public, the .env does not.

A different crew's setting stays inert there: its variants are carried strictly untouched, never materialized, and hav seal refuses (st and crew use both say so). Routing it would weave that crew's ciphertext into this crew's history — an object no single key fully reads. Inside a sealed knot the publish gate also names the deferred consequence: a "public" variant created there is ciphertext today and plaintext the day the knot is revealed, and the prompt says exactly that.

st and diff show one line per changed variant: the public variant untagged, a readable sealed variant tagged [ops-team] with real counts and path[ops-team]@N hunk tags, an opaque one as an M .env [ops-team · sealed] stub with no counts. The st header names the active crew (· using ops-team).

Each variant merges independently: cross-crew edits never conflict, and public and sealed edits at one path reconcile separately. Within one crew, members merge content like any text. A pair both sides can't reconcile is stored as a conflict variant, a public conflict object over commitments that even a keyless merger can record. Members materialize and resolve it like any conflict (hav conflicts tags its crew), and everyone else carries it opaquely.

Unsealing is a disclosure decision, so it is said out loud:

hav unseal .env           # names what it publishes, asks y/N

The active crew's variant content becomes the public variant and the sealed one is dropped going forward. Already-sealed history stays sealed, because snaps are immutable. Removal is an ordinary tree edit, protected by ordinary history semantics.

What variant sets do not hide

The path name, the exec bit, which crews have a variant, and that a variant changed are all public. Only content is sealed. The ciphertext row's size is linkable to its entry, so approximate file size leaks. Commitments are deterministic, so like sids they give an ex-member who kept keys a content-confirmation oracle against future versions. Run hav seal on a file before its first ordinary capture, because versions already in public history stay public forever (the CLI says so).

One merge limit: a machine without a crew's keys that merges two changed sides of that crew's variant, where one side is already a conflict, keeps one side and warns. It cannot flatten a conflict it cannot read, and conflicts never nest. The dropped side's content stays in its knot's history, but nothing re-merges it automatically, so a member must reconcile by hand.

Reveal

Tying sealed history into a public buoy is the disclosure, and it must be said out loud:

hav tie private-cve-2026-1234/fix main            # refused: names the sealed knots
hav tie private-cve-2026-1234/fix main --reveal   # published

Every sealed knot becoming reachable re-homes as plaintext with ids unchanged, because a snap id is the hash of the plaintext and always was. (Sealed objects are stored under a separate keyed name, so identity never depended on the ciphertext.) Sealed variants inside the revealed trees are untouched: the work goes public, a crew's .env stays sealed. Reveal is monotone: it propagates on push and pull, and while undo can restore the pointers, it cannot unpublish bytes.

Remotes and sealed history

Crew configuration (slug, generations, wraps) travels only where it is relevant:

  • with content that references the crew (its knots, or sealed files in the transferred trees);
  • as updates for crews the other side already knows, so a membership change propagates on any push or pull, whichever buoy is named;
  • on pull, for crews whose wraps name your key, so being added reaches you on an ordinary pull, even when your clone predates the crew;
  • wholesale on clone, since a clone adopts the source repo, crews and all.

The consequence for the fork/PR flow: pulling a contributor's buoy never imports the crews they made for themselves in their fork. Sealed content travels only when a member names a private-…/ buoy, and a non-member's push or pull of a private buoy is refused.

Every crew carries a write-once random identity beneath its slug, so two crews created independently under the same slug can never be mistaken for each other. Ordinary syncs skip the contested slug with a warning, and transferring content that references it is refused, because the two cannot merge. Independent key rotations on two copies of the same crew cannot be merged either, since no single key would unlock the result, so the sync is refused as diverged. Coordinate rotations through one hub.

Honest limits: anyone who can read a hub, whether a shared filesystem or any harbor with its anonymous reads, sees ciphertext, counts, slugs, and member keys. The key is the only gate on content, and nothing withholds bytes per person. A path-hub vandal with write access can delete rows, though not forge them, since AEAD authenticity means non-members cannot mint rows that decrypt. A harbor's validated pushes prevent the deletion too. Per-user access control is a hosting product's job.

On this page