Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

docs/v2/site-move.md

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:

  1. 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;
  2. on each repository the import made, and only those, the stored policy's seed_principal is pointed at import-<job>; refuse stays as the source had it (on);
  3. 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;
  4. finish puts each new repository's policy back to the source's (none = the defaults, seed_principal: installer-seed) and removes import-<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

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).