hav
Project

Proposal: per-crew variant sets

Accepted and implemented (2026-08-02)

This proposal shipped. Sealed crews is the authoritative description of variant sets now, and the command changes below are live in the CLI reference. This page stays as the design record. Where it and the current docs disagree, the docs win.

What this replaces, in one paragraph

Per-file sealing today is built on ownership: a seal-path rule gives one crew a pattern, exactly one crew may own a path, the tree entry carries a single slug, and a non-member's plaintext at an owned path refuses the whole capture. This proposal deletes ownership. A tree entry at a path holds a set of variants: optionally one public plaintext variant, plus any number of sealed per-crew variants, so ten crews can each have their own .env. No crew owns a path, no rules table exists, and capture never refuses. Edits are routed by a workspace setting rather than a rule: the active crew, whose variant is the one materialized on disk.

The model

Variant sets

Every non-directory path in a snap carries a variant set:

  • at most one public variant, ordinary plaintext (blob or public conflict), visible to everyone;
  • any number of sealed variants, at most one per crew, each a keyed commitment resolving to plaintext for that crew's members only, exactly today's sealed-entry mechanics.

A plain public file is the degenerate set (public variant, no sealed ones). Today's sealed entry is the other degenerate set (one sealed variant, no public one). Both stay expressible, and everything between becomes expressible.

Path names, the exec bit, which crews have a variant, and that a variant changed are public. Only content is sealed. Secret edits are ordinary public-history events: they advance the knot and the buoy, and they pay ordinary push coordination costs.

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

A workspace has at most one active crew, stored in the workspace row as local state that never travels, so two workspaces can differ. Setting it is one undoable op. use requires membership, and activating a crew you cannot read is refused with the ordinary not a member error. The slug use and the word none become reserved (never valid slugs) so the subcommand cannot collide with a crew name.

Materialization, per path, is a two-step fallback:

  1. the active crew's variant, if it has one;
  2. else the public variant, if there is one;
  3. else nothing: the path is absent on disk and its entry carries through capture untouched (absence of a never-materialized variant is not a deletion).

Capture routes by what materialized, so you edit what you see:

  • Active crew g, path has any sealed variant: disk edits create or update g's variant, and deleting the file deletes g's variant. All other variants, the public one included, carry through unchanged.
  • Active crew g, plain public path (no sealed variants anywhere): edits stay public. Activating a crew does not fork the whole tree, and a sealed variant at a fresh path is created only by the explicit hav seal.
  • No active crew: edits at every path go to the public variant.

Creating and removing variants

hav seal .env                  # the on-disk file becomes the active crew's variant
hav seal .env --keep-public    # …keeping the current public variant alongside
hav unseal .env                # the active crew's variant becomes the public one (asks)

hav seal <path> converts the file at <path> into a sealed variant of the active crew, from the next capture. Without --keep-public the public variant is dropped going forward, and the command says out loud that already-published versions stay public history forever, since snaps are immutable. There is no pattern, no rule row, and no standing config: the variant recorded in the snap is the routing, carried by the tree itself and propagated by ordinary history transfer.

hav unseal <path> is a disclosure and asks y/N, like every disclosure. The active crew's variant content becomes the public variant, and the sealed variant is removed going forward. Sealed history stays sealed.

The publish gate (the one guardrail)

A non-member's plaintext file at a variant path is, by design, simply the public variant, and capture never refuses. But the first capture that would create a public variant at a path that has only sealed variants says so out loud and asks:

.env has sealed variants (ops-team, design-team) and no public one — capture this file as its PUBLIC variant? [y/N]

Declining skips the path for that capture (nothing stored, nothing dropped, the question will repeat) and prints the two silencers: .havignore it, or hav seal it under a crew of yours. The reasoning: the overwhelmingly common file at such a path is a non-member's hand-kept secret copy, which today's docs explicitly bless keeping, and publishing it silently would be the exact accident the old refusal existed to prevent. Ask-then-obey preserves the design's letter (plaintext there is the public variant) while keeping hav's guardrail stance of count, notify, ask, never decide. Updating an existing public variant never asks, since that is an ordinary edit of visible content.

Representation and content-addressing

The tree entry (see objects.rs) changes shape:

{"name": ".env", "kind": "file", "exec": false,
 "id": "<public blob id>", "size": 142,
 "variants": [
   {"slug": "ops",   "kind": "sealed",   "id": "<commitment>"},
   {"slug": "ops-team", "kind": "conflict", "id": "<conflict object id>"}
 ]}
  • kind/id/size describe the public variant (file or conflict). When there is no public variant, kind is sealed, id is the all-zero hash, and size is 0.
  • variants is sorted by slug, holds at most one entry per slug, and is omitted entirely when empty, so trees without sealed variants encode byte-identically to today and every all-public snap id is preserved.
  • The per-entry slug field is deleted. A per-variant kind is sealed (id = the crew-scoped keyed commitment, unchanged derivation from store.rs::entry_commitment) or conflict (id = a public conflict object whose base and sides are commitments, today's sealed conflict, unchanged).
  • One exec bit per path, shared by all variants (the documented one-bit leak, unchanged).

Snap ids remain a pure function of names, exec bits, public plaintext ids, and per-crew commitments. Commitments are deterministic and keyed per crew, so identical content converges to identical snap ids on every machine, member or not, whatever crew is active. Reveal of a whole sealed knot keeps its ids for the same reason as today, and variants of other crews inside a revealed knot stay sealed through the reveal, since commitments are not public blob ids and the existing reveal walk already treats them so.

Existing snap ids for history that used per-file sealing change, because the entry encoding changes. Old repos have zero users, so there is no migration and no compatibility shim: the new code simply does not read the old sealed-entry encoding.

Merging, conflicts, cascades

Each variant merges independently: the public variant through the ordinary merge matrix, and each crew's variant through the same matrix over its commitments.

  • Cross-crew edits never conflict. ops-team's members and design-team's members editing their own .env variants merge to a set carrying both results. The lossy take_ours fallbacks in today's merge.rs ("two crews", "mixed sealed/plain") disappear, because those states are no longer transitional. They are the normal shape, and they merge cleanly.
  • Within one crew: members resolve content merges (unseal, text-merge, reseal, unchanged), and an unmergeable pair becomes a sealed conflict for that variant, recordable by a keyless merger as today (conflict object over commitments). Members materialize and resolve it, and everyone else carries it opaquely.
  • Rebase cascades re-merge per variant with the same independence, and a conflicted variant input flattens to rendered bytes for members exactly as today (conflicts never nest).
  • hav conflicts lists one line per conflicted variant, and hav resolve <path> --take <side> acts on the variant the workspace materializes, the one whose markers are on disk.

Display grammar

Single-line-per-fact: one line per changed variant rather than per path. The public variant is untagged, a readable sealed variant is tagged with its slug and shows real counts, and an unreadable one is an opaque stub with no counts.

$ hav st
ws-1 · ~/proj — tracking main (in sync) · using ops-team
knot kn-8c2f41d2 — rotate the api key
  M src/app.rs +12 -3
  M .env +1 -1  [ops-team]
  M .env  [ops · sealed]
  conflict secrets/api.pem  [ops-team]  both-modified
  • st header: · using <slug> when a crew is active, omitted otherwise.
  • Changed readable variant: M <path> +A -R [<slug>].
  • Changed opaque variant: M <path> [<slug> · sealed], with no counts ever, since counts derived from opaque tokens are noise.
  • diff: each changed variant emits its own file section. The public variant uses the bare path in the ---/+++ headers, and a sealed variant suffixes [<slug>]. Members see real unified diffs, and an opaque variant renders the stable one-line token per version: [variant · <slug> · <commitment-prefix-8>]. An opaque sealed conflict renders [variant conflict · <slug> · <id-prefix-8>].
  • Hunk tags become variant-scoped where a sealed variant is involved: path@N for the public variant and path[slug]@N for a sealed one. split --take and absorb accept both forms.
  • show inherits diff's grammar, and log is knot-level and unchanged.
  • hav crew listing drops the seals: <patterns> column, since no rules exist. Crews are otherwise listed as today.

Resolved questions

  1. Default active crew after init and after clone: none. Agreed with the proposal. Materializing secrets is an explicit act, and a fresh clone showing the public view is the least-surprise default. ws new likewise starts with none, since workspace state never travels or inherits. hav adds no auto-activation on join, unopinionated by policy.
  2. Zero memberships / active crew has no variant at a path: the fallback above applies, so the public variant materializes, and with no public variant the path is absent on disk and its entry carries through. A member of zero crews gets exactly today's non-member experience minus the refusal.
  3. Sealed variants with no public variant: allowed. It is today's sealed entry, unchanged, and the common case for a real secret. Non-members see the path name, exec bit, opaque stubs in st and diff, and nothing on disk. A hand-kept file at the path is left strictly alone by projection and guarded by the publish gate at capture.
  4. Active variant collides with public plaintext on disk: crew use captures first, like every command, so pending public edits land in the public variant before the switch. Then the active variant projects over the now-tracked content, and a tracked overwrite is always safe. A genuinely untracked file at the path backs up to <name>.hav-saved with a warning, reusing the ordinary collision rule unchanged. No data is ever silently destroyed.
  5. Representation: as specified above, with the public shape in the entry, a sorted variants vector, the slug field deleted, a zero-hash placeholder for public-less entries, and variants omitted when empty. All-public snap ids are preserved byte-for-byte, and per-file-sealed history re-ids (no users, no migration).
  6. Display: the grammar above, with one line per changed variant, [slug] tags on readable variants, [slug · sealed] stubs on opaque ones, [variant · slug · cmt8] diff tokens, and path[slug]@N hunk tags.
  7. Migration: see the next section.
  8. Contradictions found: see the last section.

Migration: reused, deleted, tests

Reused unchanged, since the crypto and storage substrate has no ownership baked in:

  • sealed_objects/sealed_entries tables, commitment + placement + nonce derivations (store.rs::entry_commitment/placement are already crew-scoped and path-free), put_sealed_entry/resolve_sealed_entry.
  • Sealed conflict objects (public conflict over commitments) and their member/keyless materialization split (merge.rs::conflict_entry_bytes).
  • The carry-through principle (capture.rs::carry_locked_sealed generalizes from "locked crews" to "every non-materialized variant").
  • Projection's materialize-or-leave-alone logic and the .hav-saved collision backup.
  • Wire/harbor transport of sealed rows (variants travel keylessly to everyone; hub-holds-no-plaintext invariants unchanged).

Deleted:

  • rules.rs entirely; the crew_rules table, rule revisions and tombstones; CrewView.rules in view.rs; rule rows in remote config sync; --seal-path/--unseal-path and the one-owner check.
  • The capture refusal (worktree.rs locked-path scan failure), replaced by the publish gate.
  • The harbor's seal-path enforcement (check_sealed_paths and the rule-regression checks). With no ownership there is no "plaintext where a rule says sealed" to refuse. Stated honestly: the server no longer prevents any plaintext at any path. A public variant is legitimate everywhere, and disclosure control is the key plus the client-side publish gate.

Tests: sealed-entry round-trip, convergence/idempotence, carry-through, sealed-conflict (member resolves / locked carries), projection leave-alone, wire and hub-leak tests all carry over with setup changed from rules to hav seal. Deleted with their features: capture_refuses_locked_sealed_plaintext (inverts into a publish-gate test), rules.rs unit tests, harbor_refuses_seal_rule_regressions, harbor_refuses_plaintext_at_a_sealed_path, and the unseal-path revision-propagation tests. hav unseal needs no anti-resurrection machinery, because removing a variant is an ordinary tree edit protected by ordinary history semantics instead of rule revisions.

Contradictions found in the current code

  1. worktree.rs scan routing hard-wires one owner per path and refuses locked plaintext mid-walk. This design deletes both, and routing moves from scan-time rules to materialization state.
  2. "A member's absence is a deletion" is keyed on membership, not materialization (capture.rs::carry_locked_sealed). Under variant sets an ops-team member with ops-team inactive never had the file on disk, so absence must not delete. The active crew, not membership, must drive deletion semantics.
  3. ManifestEntry is one (kind, id, slug) per path (worktree.rs). Everything downstream (diff_counts, diff summaries, projection's unchanged check, conflict reconciliation) assumes one content slot per path and needs to become variant-aware.
  4. merge.rs::merge_sealed_path treats two-crews-one-path and mixed-sealed/plain as broken states resolved by lossy take_ours warnings. Under this design they are the normal case and must merge per-variant without loss.
  5. objects.rs::TreeEntry.slug encodes exactly-one-crew-per-entry in the canonical object format itself, and the representation change above is what removes it.
  6. status.rs infers "opaque" from zero counts on a sealed path. With readable and opaque variants coexisting at one path the inference is wrong, and per-variant readability must be explicit.
  7. hav crew listing prints per-crew seals: patterns from the rules table. Column and table both go.
  8. Sealed knots (whole-buoy crews) route every object write through the knot's ambient crew and refuse entries sealed under a different slug (worktree.rs). Variant sets inside a sealed knot are representable, since a tree is just data, itself encrypted under the knot's crew, but v1 of this design should state plainly: variants are a public-tree feature. On a sealed knot the whole tree is already crew-encrypted, the active-crew setting is ignored, and hav seal is refused there. One sealing regime per knot.

Command reference changes (docs/reference/cli.mdx)

Removed:

  • hav crew <slug> --seal-path <pattern>
  • hav crew <slug> --unseal-path <pattern>

Added under "Crews & sealing":

hav crew use <slug> / hav crew use --none

Set or clear this workspace's active crew (membership required; local state, one undoable op). The active crew's variants materialize on disk and receive your edits at variant paths, and with none active you see and edit the public variants. use and none are reserved words, never valid slugs.

hav seal <path> [--keep-public]

From the next capture, the file at <path> stores as the active crew's sealed variant. Drops the public variant going forward unless --keep-public, and already-published versions stay public history forever. There is no rule and no pattern: the variant in the snap is the routing. Refused on a sealed knot, where the whole tree is already sealed.

hav unseal <path>

Publish the active crew's variant at <path> as the public variant and drop the sealed one going forward. A disclosure: names what it publishes and asks y/N. Sealed history stays sealed.

Changed:

  • hav st: the header gains · using <slug>, and there is one line per changed variant per the display grammar above.
  • hav diff and hav show: per-variant sections and opaque tokens per the display grammar.
  • hav split --take and hav absorb: accept variant-scoped hunk tags path[slug]@N.
  • hav resolve <path> --take <side>: acts on the variant the workspace materializes.
  • hav crew: listing drops the seals: column.

Glossary additions: variant set (the per-path set of one optional public variant plus per-crew sealed variants) and active crew (the workspace's materialized crew, set by hav crew use). The seal-path rule entry is removed.

On this page