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 viewA 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:
- the active crew's variant, if it has one;
- else the public variant, if there is one;
- 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 updateg's variant, and deleting the file deletesg'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 explicithav 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/sizedescribe the public variant (fileorconflict). When there is no public variant,kindissealed,idis the all-zero hash, andsizeis 0.variantsis 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
slugfield is deleted. A per-variantkindissealed(id = the crew-scoped keyed commitment, unchanged derivation fromstore.rs::entry_commitment) orconflict(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
.envvariants merge to a set carrying both results. The lossytake_oursfallbacks in today'smerge.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 conflictslists one line per conflicted variant, andhav 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-modifiedstheader:· 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@Nfor the public variant andpath[slug]@Nfor a sealed one.split --takeandabsorbaccept both forms. showinheritsdiff's grammar, andlogis knot-level and unchanged.hav crewlisting drops theseals: <patterns>column, since no rules exist. Crews are otherwise listed as today.
Resolved questions
- 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 newlikewise starts with none, since workspace state never travels or inherits. hav adds no auto-activation on join, unopinionated by policy. - 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.
- 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
standdiff, 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. - Active variant collides with public plaintext on disk:
crew usecaptures 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-savedwith a warning, reusing the ordinary collision rule unchanged. No data is ever silently destroyed. - Representation: as specified above, with the public shape in the entry,
a sorted
variantsvector, theslugfield deleted, a zero-hash placeholder for public-less entries, andvariantsomitted when empty. All-public snap ids are preserved byte-for-byte, and per-file-sealed history re-ids (no users, no migration). - 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, andpath[slug]@Nhunk tags. - Migration: see the next section.
- 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_entriestables, commitment + placement + nonce derivations (store.rs::entry_commitment/placementare 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_sealedgeneralizes from "locked crews" to "every non-materialized variant"). - Projection's materialize-or-leave-alone logic and the
.hav-savedcollision backup. - Wire/harbor transport of sealed rows (variants travel keylessly to everyone; hub-holds-no-plaintext invariants unchanged).
Deleted:
rules.rsentirely; thecrew_rulestable, rule revisions and tombstones;CrewView.rulesinview.rs; rule rows in remote config sync;--seal-path/--unseal-pathand the one-owner check.- The capture refusal (
worktree.rslocked-path scan failure), replaced by the publish gate. - The harbor's seal-path enforcement (
check_sealed_pathsand 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
worktree.rsscan 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.- "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. ManifestEntryis one(kind, id, slug)per path (worktree.rs). Everything downstream (diff_counts, diff summaries, projection'sunchangedcheck, conflict reconciliation) assumes one content slot per path and needs to become variant-aware.merge.rs::merge_sealed_pathtreats two-crews-one-path and mixed-sealed/plain as broken states resolved by lossytake_ourswarnings. Under this design they are the normal case and must merge per-variant without loss.objects.rs::TreeEntry.slugencodes exactly-one-crew-per-entry in the canonical object format itself, and the representation change above is what removes it.status.rsinfers "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.hav crewlisting prints per-crewseals:patterns from the rules table. Column and table both go.- 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, andhav sealis 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 --noneSet 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.
useandnoneare 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 diffandhav show: per-variant sections and opaque tokens per the display grammar.hav split --takeandhav absorb: accept variant-scoped hunk tagspath[slug]@N.hav resolve <path> --take <side>: acts on the variant the workspace materializes.hav crew: listing drops theseals: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.