Moving a whole site: gunnar control export and gunnar control import
A Gunnar site moves to a new site with two commands, accounts included: people, their SSH keys and passkeys, groups, grants, namespaces with their settings, every repository with every reference, and the history the ai-trailers gate would refuse if it were pushed fresh. The export is one archive, encrypted to the importer's key. The import merges it into a site that is already laid and enrolled, and never overwrites what that site has.
The owner ordered it on 2026-11-07, after that day's move of gunnar.rs lost the passkeys and needed a temporary grant and the installer's seed key to carry the history across.
The dataflow
OLD SITE ARCHIVE (age( znippy( dir ) )) NEW SITE (laid, enrolled)
Admin.SiteExport ─┐ no pause site.json ──────────────┐
(or, on an older gunnar, audit/chain.jsonl │ Admin.SiteImportStart
Mirror.SnapshotFetch: forge/events.jsonl │ plan = merge(site.json, target)
writes PAUSED, printed) secrets/sealed.json (encrypted only) │ queue job: apply
├──────────────▶ MANIFEST.txt ├──▶ accounts, namespaces, empty repos,
git clone --mirror ┘ mirror/<ns>/<name>.git ─────┐ │ grants; open the exemption
(every repository) │ │
└─────┼──▶ git push (all refs, one push per
│ new repository) as import-<job>
└──▶ Admin.SiteImportFinish
compare refs, close the exemption
Every step on the new site is a queued job (site-import, its own jera lane). Its record is kept under <data root>/site-imports/<job>.json, and Admin.SiteImportStatus reads it: the CLI's --status, and the console's Administration page (/api/site-imports).
The archive
The archive is a znippy archive of one directory:
| path | what |
site.json |
the export document, format gunnar-site-export/1 (gunnar_core::site_move::SiteExport) |
audit/chain.jsonl |
the source's audit chain, verbatim, one entry per line |
forge/events.jsonl |
the source's forge log (issues, reviews), verbatim |
secrets/sealed.json |
the source's KEK (hex) and every sealed row: only in an encrypted archive |
mirror/<ns>/<name>.git |
a bare git clone --mirror of every repository |
lfs/<ns>/<name>/<oid> |
every LFS object of every repository, named by its sha256 |
MANIFEST.txt |
the counts, in words |
site.json:
{ format: "gunnar-site-export/1",
source: { site, rp_id, via: door | snapshot | data-root, exported_at_ms, write_pause_ms, at_seq },
principals: [ { principal: {id, name, kind, created}, estate: null | "why",
keys: [PublicKey], passkeys: [WebCredential], member_of: [group] } ],
grants: [ {principal, scope: Server | {Namespace} | {Repo}, level} ],
namespaces: [ {name, public, events_public, explode_budget_bytes} ],
repos: [ { repo: {namespace, name, hash_kind, default_branch, visibility, …},
policy: null | "<stored policy JSON>", refs: { "<ref>": "<oid>" },
lfs: { "<sha256>": <size> } } ],
carried: { audit_entries, audit_verified, forge_events, secrets } }
Passkey session keys (the short-lived keys a passkey login enrols) are left out: they die with the session. A passkey's sessions list is emptied.
Encryption, and the KEK
--to <ssh public key> (repeatable, or @file) encrypts the whole archive with age (the age crate 0.11, ssh-ed25519 and ssh-rsa recipients; the crate is already in Gunnar's graph through gunnar-crypt). znippy has no encryption of its own. The file on disk is an age file whose plaintext is the znippy archive; the importer opens it with the matching private key (--decrypt-with, default -i).
Only an encrypted archive carries the KEK and the sealed secrets. Without --to an export is refused, unless --unencrypted-without-secrets says to write it in clear without them.
An import never adopts the KEK and never writes a sealed row. The new site has its own KEK, sealed its own machine secrets at its install, and adopting another would make every one of them unreadable. The old KEK travels so that nothing is lost: a sealed secret a person needs later (a mirror credential, say) can be opened from the archive by hand. MANIFEST.txt names the KEK by id = hex(SHA-256(kek_hex))[0..12], which says which key without revealing it.
The export
document = Admin.SiteExport if the site serves it (no pause)
| install(Mirror.SnapshotFetch) → read otherwise (pause printed)
| read(--from-data-root) offline, an installed snapshot on disk
refs(r) = for-each-ref( git clone --mirror r ) for every repository r (or --mirrors <dir>)
lfs(r) = Refs.ReadLfsObject(r, oid), in ranges door: each object the document names
| the kept snapshot base's Lfs items snapshot, data-root (a store-only twin)
| <git dir>/lfs/objects data-root with materialised repositories
What an installed snapshot keeps where. A store-only twin (which is what an installed snapshot is) does not apply the snapshot's data items: the packs, references, LFS objects and files stay in its kept base, txlog-snapshot-base/ (items + blobs). The repositories come from the clones, so only three things are read out of the base: the File items log/audit-chain.jsonl (the audit chain) and forge.jsonl (the forge log), and every Lfs item. On gunnar.rs's snapshot of 2026-11-07 that is 300 MB of audit chain, 9.8 KB of forge log and 338 LFS objects (418 MB) in 13 repositories, none of which a git clone --mirror carries.
Admin.SiteExport reads the identity table, the catalogue and the policies through their current documents: each one read, nothing frozen. A gunnar from before it (gunnar.rs 59912eff) answers UNIMPLEMENTED, and the CLI takes the snapshot path: it installs one Mirror.SnapshotFetch into a scratch data root (the same staging and install as twin catch-up, without the follow), reads it, and deletes the scratch root, which holds the source's KEK in clear. The snapshot holds the source's framed writes for the whole stream; the CLI prints the pause when it opens and when it ends (gunnar.rs on 2026-11-07: 86 s).
A git mirror does not carry LFS objects, so they travel beside it, each checked against its sha256 when it is read over the door. The references in the archive are the clone's, which is what was moved. When the clone and the document disagree (a push in between), the CLI says how many repositories moved.
The import: the merge
For each kind of item, with S the source's set and T the target's:
add = S \ T (by key: principal id, key fingerprint, credential id,
(group, member), namespace name, repository path,
(principal, scope) of a grant, reference name)
same = { x ∈ S ∩ T : S[x] = T[x] }
differs = { x ∈ S ∩ T : S[x] ≠ T[x] } reported; T[x] kept
conflict = items whose key is in T bound to something else:
a key fingerprint on another principal,
a credential id on another principal,
a principal id of another kind, or one of the target's estate machines,
a reference name pointing at another object,
a repository of another hash kind
skipped = estate machines, --skip, and everything hanging on a skipped or refused principal
A conflict refuses that one item, names it, and the import goes on. A reference that differs is never forced; a reference the target has and the source does not stays.
Estate machines are the principals the estate lays itself: gunnar-ui, gunnar-guest, front-certify, monetize, gunnar-twin (group twin), repair, installer-seed, any principal holding an X.509 certificate, and an import's own import-<job>. They belong to the box they were laid with: never imported, and the target's are never touched. Machines and deploy keys people made (dwarves-oden, rebuild) move like accounts.
Renames. --rename OLD=NEW takes a source principal in under another id; its account namespace (the namespace named after it), that namespace's repositories and every grant on them move with it. --skip NAME leaves a principal, namespace or repository out.
Passkeys are bound to the site's bare name (the WebAuthn RP ID), fixed when the front is laid. They are imported as records; they log in only on a site of the same name. The CLI says so when the names differ.
--dry-run prints the plan, one line per item, and changes nothing: the server computes the plan from a read of its stores and keeps no record.
History, and the ai-trailers gate
A repository the import makes is new and empty, so its whole history is new to the forge and the gate (gunnar-policy, gate 6) would judge every historical trailer. The gate already has one exemption, the installer's seed, with two halves both checked: a machine principal named by the policy's ai_trailers.seed_principal, pushing into a repository with no references. The import uses exactly that, scoped:
- the job makes the machine principal
import-<job>with the key the CLI minted for this import (expiring in 24 h if the import is never finished), a WRITE grant on each repository it is to push, and nothing else; - on each repository the import made, and only those, the stored policy's
seed_principalis pointed atimport-<job>;refusestays as the source had it (on); - the CLI pushes every reference of a new repository in one push, so the exemption is spent by that push: from the first reference on,
import-<job>'s pushes are judged like anybody else's; - finish puts each new repository's policy back to the source's (none = the defaults,
seed_principal: installer-seed) and removesimport-<job>with its key and grants.
Missing references of a repository the target already has are pushed by the same principal and judged as always. The gate is never turned off.
LFS objects the target lacks are uploaded by the same principal with the stock git-lfs client (git lfs push --object-id, which speaks git-lfs-transfer over ssh), from the bare mirror they are linked into; finish checks each one is in the repository's LFS store with its size.
What travels and what does not
| item | export | import |
| people (users), their keys and passkeys | yes | merged |
| machines and deploy keys people made | yes | merged |
| the estate's machines | marked | never |
| groups and memberships | yes | merged |
| grants | yes | merged (a different level is reported, the target's kept) |
| namespaces: public, events public, cache budget | yes | merged |
| repositories: visibility, default branch, hash kind, policy | yes | new ones made as the source had them; existing ones kept |
| every reference | yes | added where missing; a different one is a conflict |
| every LFS object | yes | uploaded where missing (git lfs push --object-id, over ssh) |
| audit chain | yes, verbatim | carried in the archive; not spliced into the target's chain (a hash chain has one writer) |
| forge log (issues, reviews) | yes, verbatim | carried in the archive; not replayed |
| KEK, sealed secrets | encrypted archive only | never applied |
| SSO providers, encryption keys, mirrors, webhooks, entitlements, account gates | no | no |
The commands
gunnar control export --at <old site> -i <admin key> \
--git ssh://git@git.<old site>:2222 \
--to @importer.pub [--to …] -o site.znippy
gunnar control export … --unencrypted-without-secrets -o site.znippy
gunnar control export --from-data-root <installed snapshot> --mirrors <dir> --to … -o …
gunnar control import --from site.znippy --at <new site> -i <admin key> --dry-run
gunnar control import --from site.znippy --at <new site> -i <admin key> \
--git ssh://git@git.<new site>:2222 [--rename OLD=NEW] [--skip NAME]
gunnar control import --status all --at <new site>
The server half is Admin.SiteExport, Admin.SiteImportStart, Admin.SiteImportStatus and Admin.SiteImportFinish (admin.proto): server-wide ADMIN, each audited before it acts, an export with secrets refused unless its audit record is durable.
A worked example: gunnar.rs, 2026-11-07
The old gunnar.rs (gunnar 59912eff, no Admin.SiteExport) held 13 principals, 3 passkeys, 7 grants, 16 namespaces and 47 repositories; 14 of them carry historical trailer commits (gunnar 229, workspace_nordisk 154, monetize 97, …). Lane gmove took one Mirror.SnapshotFetch of it (installed into a data root; the old site's writes paused 86 s) and a git clone --mirror of every repository, and archived both on dwarf. That archive is the input here; the numbers are the local proof's (below), on dwarf, never on either live site.
export --from-data-root <the installed snapshot> --mirrors <the mirrors> --to @importer.pub
read via data-root, as of seq 3025698, in 89 s; the archive 1 873 829 899 bytes (age)
principals 6 to move: users admin, alica, henrik, rickard; machine dwarves-oden;
deploy rebuild
7 estate (never imported): front-certify, gunnar-guest, gunnar-twin, gunnar-ui,
installer-seed, repair, group twin
keys 23 (passkey session keys left out); 16 of them people's
passkeys 3 (henrik 2, rickard 1), bound to gunnar.rs
grants 7, namespaces 16
repositories 47, 5 210 references, 338 LFS objects (418 MB, 13 repositories)
audit chain 730 854 entries, verified; forge log 16 events
secrets the KEK (id 151fa915d9f3) and 7 sealed rows, inside the encryption
import into a fresh, enrolled main that already has rickard (a new key) and henrik (his first)
plan add 138, same 8, differs 0, conflict 0, skipped 24
apply + 47 pushes (one each, all references) + 338 LFS objects + finish: 135 s
finish 47 repositories equal to the source; the exemption closed
On the new gunnar.rs, which lane gmove filled with every repository but no LFS object, the same import adds the accounts' missing keys and passkeys, finds every reference same, and uploads the 338 LFS objects nobody has moved yet: the merge rig (proof step 9) is shaped like it.
Proof
gunnar-coresite_move::tests(5): the merge keeps the target and adds what it lacks; a kind clash is refused until renamed; missing references are added and never forced; LFS objects the target lacks are uploaded; the document round-trips and refuses another format.gunnar-cli/tests/site_move.rs(3), two real servers through the binary: export through the door and through the snapshot (the same KEK id, the pause printed), an export without--torefused, a dry run that leaves the target equal, the import with an existing account, a key on another principal, a kind clash and an estate machine on both sides, the trailer history arriving and the gate refusing a new trailer afterwards; a differing reference is a conflict and the target's stands; a 5 MiB LFS object read over the door in ranges arrives byte-equal.- gunnar-ui:
/api/site-importsis an administrator's read (a viewer and an operator are refused with no call reaching gunnar); the panel's words and polling (3 tests). -
The local proof with the real archive (
/backup/gexport/proof/proof.shon dwarf, logrun/proof.log), every step on loopback:- the archive's sha256 checked (8ced2894…); export as above, encrypted;
- a rig:
gunnar servewith a bootstrap admin, its owngunnar-ui,monetize,installer-seed, andrickard,henrikalready there; - dry run: the plan printed, the principals unchanged;
- import: 47 repositories equal, exemption opened and closed,
import-*principals left: 0; git ls-remoteof every repository against the old mirror: 47 same, 0 different;nordisk/gunnarcloned (3 579 commits, 226 with an AI trailer, its LFS file checked out); a NEW trailer commit pushed by the admin: refused by the ai-trailers gate;- the rig's own export through the door, compared item by item with the source document: principals 6/6, keys 16/16, passkeys 3/3, grants 5/5 (the two estate ones skipped), namespaces 9/9, repositories 47/47, visibility 47/47, references 5 210/5 210, LFS objects 338/338; a second dry run: add 0, same 133; the rig's machine principals untouched;
- a twin following the rig's main (
twin follow): its identity table equal to the main's, every LFS object in its log; - a merge rig like the new gunnar.rs: rickard and henrik there, the repositories pushed the gmove way (
installer-seed,push --mirror, no LFS; 43 of 47 took, the 4 SHA-1 ones were refused by push-to-create's SHA-256 default, so the import made those). The plan: add 49, same 68, differs 29, conflict 0, skipped 24; the import took 87 s and uploaded 338 LFS objects into 13 repositories. Read back: principals 6/6, keys 16/16, passkeys 3/3, grants 5/5, repositories 47/47, references 5 210/5 210, LFS 338/338. The 28 repositories push-to-create had made private (the source has them public) and three default branches stay as the target has them, reported asDIFFERS: merge, never overwrite. An operator who wants the source's visibility sets it after reading the report.
What is not proven: an import into the live new gunnar.rs (by order, never run here); an export from the live old gunnar.rs (the snapshot fallback is proven against a local gunnar 59912eff, the same release, and against an installed snapshot of the old site); passkeys logging in on a site of the same name (the records arrive; a browser login was not driven).