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

nordisk/Skidbladnir

.nornir/uninstall-design.md

UNINSTALL — THE LAW, and the design that enforces it

skidbladnir::uninstall (no feature gate — it is the lifecycle, not an extra). Guide: uninstall-guide.md. Never done this before: uninstall-for-idiots.md.


THE LAW

uninstall ALWAYS kills the DATA and ALWAYS keeps the SSH KEYS.

"make the data directory the SAME regardless of who fucking owns it"

— Rickard, 2026-07-27, after explaining it four times

Not behind a flag. Not opt-in. Not --purge. Plain Lifecycle::uninstall() does BOTH halves, every time, in every suite application — because the behaviour lives in the ship, and every consumer inherits it instead of re-inventing it.

The suite-wide statement of the law is workspace_nordisk/.nornir/suite-uninstall-law.md. This document is Skidbladnir's own: what the ship implements, and why.


Why the ship owns this — the four incidents

Four applications implemented uninstall four different ways and got it wrong four different ways. None of these is hypothetical; all four were measured on 2026-07-27.

app what it did result
holger remove_dir_all("/home/holger") and then userdel the ssh deploy key destroyed twice over — once with the home, once with the account that owned it
dwarves its resolver used one path and its eraser purged another (a hardcoded /home/dwarves) a per-user install wiped nothing, left a 5.3 GB ~/.nornir intact, and printed ✓
korp the same two-ladder bug with /home/korp same silent no-op
nornir required --purge to do the only correct thing — and that flag ALSO took the key + token either nothing was cleaned, or the identity was destroyed

Two further findings from the conformance audit made it worse:

One law, one implementation, one test shape — so a fifth interpretation cannot be invented.


The two halves

1. Data DIES

Everything under the data root: warehouses, registries, caches, funnel state, job ledgers, workspace trees. After an uninstall the application holds nothing the next install can be confused by. A "clean slate" that keeps state is not a clean slate.

2. Keys LIVE

Any ssh key directory and any stored token survives byte-identical. The deploy key is registered on the private git remotes; destroying it means a reinstall cannot clone anything until a human re-registers a new key on every repo. That is not a clean slate — it is an outage.

The spared set is uninstall::IDENTITY_NAMES:

.ssh · ssh · token · id_ed25519 · id_ed25519.pub · id_rsa · id_rsa.pub · authorized_keys · known_hosts

…plus any name beginning with a key STEM in uninstall::IDENTITY_STEMS:

id_ed25519 · id_rsa · id_ecdsa · id_dsa

A file name matching an exact entry or starting with a stem is identity. A directory that contains one is walked entry by entry instead of swept, so .nornir/ dies down to nothing but its ssh/ and its token.

Incident 2026-07-28 — the DOTLESS ssh directory

Caught before it ran, on t14s. IDENTITY_NAMES matched by exact name and listed only .ssh. nornir does not use .ssh: it keeps its deploy key in a dotless directory, <data-root>/ssh/id_ed25519 (a real ~/.nornir/ssh, mode 0700). dwarves-full --uninstall sweeps ~/.dwarves and ~/.nornir, so ssh was classified as DATA and the sweep would have erased the deploy key registered on every private codeberg remote.

Nothing stood behind the list, contrary to what this document used to claim: the guarded_remove_dir_all KeyMaterial refusal keys on a .ssh component, which a dotless ssh does not have. The list was the only guard, and it was wrong.

Two consequences, both fixed:

The rule is name-based, therefore user- and scope-agnostic: the same schema holds under /home/rickard/.nornir (per-user install) and under a dwarves/korp service-account home (system install). There is no $HOME branch and no scope branch — which is the same principle the ONE data root section below is built on.


ONE data root — no $HOME ladder, no scope branch

CONTRACT. uninstall::DataRoot is one absolute path, declared on the spec. It is not derived from $HOME, not from the CWD, and not branched on Scope. The unix account that owns the process is orthogonal to where the product keeps its state.

let root = DataRoot::new("/var/lib/dwarves")?;          // validated HERE
let spec = ServiceSpec::new("dwarves", "…", "/usr/local/bin/dwarves")
    .user("dwarves")
    .data_root(&root);

plan_state_erase and execute_state_erase read that one declaration. That is the fix for the actual incident: dwarves' resolver and dwarves' eraser were two separate ladders, so the eraser purged a path the resolver never used.

If a resolver and an eraser can disagree about where state lives, one of them is lying.

Refused at DECLARATION time

A scope-aware $HOME root was tried in dwarves the same afternoon and was a near-miss: under user scope the root resolved to /home/rickard, and the sweep would have taken .rustup, work, .njord-secrets, .bashrc, GolandProjects. guarded_remove_dir_all refused every single one — the guard earned its keep — but an erase must not depend on a downstream guard catching a bad root.

So DataRoot::new refuses, with a named DataRootRefusal, a root that:

refusal example
NotAbsolute dwarves-data — resolves against the CWD, and a unit's WorkingDirectory=/ makes that /
TooShallow { components } /data
SystemDir /, /etc, /usr, /var, /home, /opt, /srv, /proc…
IsUserHome { user } /home/rickard
ContainsUserHome { user, home } anything a sweep would swallow a home through

Being inside a home is allowed — a dev box may point DWARVES_DATA at ~/.dwarves, and the legacy roots a migration must still sweep live there. What is refused is a root that is a home or would swallow one.

The plan re-validates the root against the machine it is about to erase on, so a spec that arrived over the wire in an airgap bundle (validated on a different machine's passwd file) is checked again. A refused root produces an EraseMode::Refused plan carrying the reason — never a quiet fall-through to some other path.

The one sanctioned env override

DataRoot::from_env("DWARVES_DATA", "/var/lib/dwarves") — reads the var, falls back to the default, validates whatever wins, and feeds the same single declaration both the resolver and the eraser consume. It never becomes a second ladder.

Owner ≠ path

EraseOwner picks who owns the tree, never where it is:

pub struct EraseOwner { service_uid: u32, invoking_uid: u32, invoking_home: Option<PathBuf> }

A tree inside the invoking user's $HOME is erased as the invoking user; everything else as the service account. This is the fix for the uid half of the incident, and it cannot move the data root — there is still exactly one of those.


Migration — the orphans, stated out loud

A fixed root makes pre-existing per-user state (~/.nornir was 5.3 GB on this box, ~/.dwarves 548 K) into orphans no future uninstall would ever find. Nothing is silently migrated and nothing is silently ignored: a spec may declare legacy roots that an uninstall ALSO sweeps, by the same law, each named in the report.

let spec = ServiceSpec::new("dwarves", "…", "…")
    .data_root(&DataRoot::new("/var/lib/dwarves")?)
    .legacy_data_root("/home/rickard/.dwarves")   // pre-fixed-root layout
    .legacy_data_root("/home/dwarves/.dwarves");

Each legacy root goes through the same DataRoot validation. One that is a real user's home is refused and reported, never swept.

What a consumer must do about existing state: declare its old per-user paths as legacy roots for at least one release, ship that, and only then drop them. A consumer that does not will leave those bytes on disk forever — and should say so in its own docs rather than pretend the machine is clean.


The identity opt-in

UninstallOptions::wipe_identity(true) destroys .ssh, the token, and every IDENTITY_NAMES entry under the data root.

⚠ The ssh deploy key registered on the git remotes will be destroyed. A reinstall afterwards cannot clone anything until a human generates a NEW key and registers it on every private repo. That is an outage for as long as it takes.

It is impossible to trigger as a side effect:

Never remove the account that owns the key

UninstallOptions::remove_account(true) without wipe_identity(true) is REFUSED by UninstallOptions::validate(), which names the holger incident in the error. Removing the account destroys the key as a side effect; the key must be handled first, or the removal is refused. With both set, the order is: erase → destroy identity → then remove_system_user (which itself refuses any uid outside [100,1000) and never touches a home).


Reporting honestly

UninstallReport carries erased, kept, refused, legacy_swept, identity_wiped, account_removed, notes.

That is the whole point of the honesty requirement: the operator who reads ✓ believes the server is fresh. On 2026-07-27 it was not.


The public surface

// the declaration
DataRoot::new(path) / new_in(path, passwd) / from_env(var, default) -> Result<DataRoot, DataRootRefused>
DataRootRefusal { NotAbsolute, TooShallow, SystemDir, IsUserHome, ContainsUserHome }

// the spec
ServiceSpec::data_root(&DataRoot) · legacy_data_root(s) · state_dir(s)
SystemdServiceSpec::with_data_root(&DataRoot) · with_legacy_data_root(s) · with_state_dir(s) · state_spec()

// the plan (pure — assert it in a test, without deleting anything)
plan_state_erase(&StateSpec, passwd_path) -> StatePlan { root, mode, legacy, notes }
EraseMode::{ SweepChildren(root), Declared(paths), Refused(why) }
Lifecycle::state_plan() -> StatePlan

// the verbs
Lifecycle::uninstall()                 -> Result<UninstallReport>   // THE LAW
Lifecycle::uninstall_with(opts)        -> Result<UninstallReport>   // the opt-ins
systemd::remove_systemd_scoped(spec, scope)            -> Result<UninstallReport>
systemd::remove_systemd_scoped_with(spec, scope, opts) -> Result<UninstallReport>
install::uninstall_product(spec, layout, backend, root, _purge) -> Result<InstallOutcome>
install::uninstall_product_with(spec, layout, backend, root, opts)
product::Installer::remove_with_layout(spec, layout)   -> Result<UninstallReport>

// the primitives (one authority, never re-implemented)
systemd::guarded_remove_dir_all(path, uid, allow_root)     // data — refuses .ssh, always
systemd::wipe_identity_material(path, uid, root)           // identity — the ONLY escape hatch
systemd::remove_system_user(user)                          // account rows only, never a home
systemd::current_euid() · lookup_system_uid · lookup_system_home · is_system_dir · passwd_homes_of

One eraser, not three

The audit found three implementations inside Skidbladnir, two with doc comments stating the opposite of their code. They are collapsed:

was now
systemd::remove_systemd_scoped — the law unchanged authority; drives plan_state_erase + execute_state_erase
install::uninstall_product — purge-gated, raw remove_dir_all behind the gate erases data + log unconditionally, through the guarded identity-sparing eraser. purge is ignored and documented as ignored
product::SystemdInstaller::remove — never erased anything (nornir calls it) still unit-only, because it has no Layout and therefore no data root — but it now says so on stderr. The law is Installer::remove_with_layout(spec, layout)

Proof — every test is revert-checked

test proves goes RED when
uninstall_kills_the_data_and_keeps_the_key_and_token data gone, key + token byte-identical the identity skip is removed
the_identity_opt_in_destroys_key_and_token the opt-in really destroys them wipe_identity_material stops being wired
the_data_root_is_one_declaration_never_derived_from_home_or_owner the plan does not move when $HOME moves a $HOME/scope ladder returns
a_data_root_that_is_or_contains_a_users_home_is_refused_at_declaration the refusal happens where it is written the declaration-time guard is dropped
data_inside_the_invoking_users_home_is_actually_erased the uid half — a tree inside the real $HOME is erased, not refused EraseOwner::uid_for stops preferring the invoking uid ⇒ "THE UID BUG IS BACK — the guard refused data inside $HOME"
a_noop_uninstall_says_nothing_was_removed ✗, never ✓, over a no-op is_noop/summary start lying
removing_the_account_without_wiping_the_identity_is_refused holger's combination is refused, by name validate() is relaxed
a_legacy_root_is_swept_by_the_same_law_and_named migration sweeps and reports legacy roots are dropped
uninstall_product_always_erases_data_and_keeps_keys the product limb obeys the law regardless of purge the erase is re-gated ⇒ "THE DATA MUST DIE, flag or no flag"
public_uninstall_law_one_data_root_data_dies_keys_live the whole law through the public seam only any of the public names change shape (fails to COMPILE)
uninstall_keeps_the_dotless_ssh_dir_and_rotated_key_spellings the law over nornir's REAL layout — ssh/ with no dot, plus an id_ed25519.*.bak — while workspaces/ + warehouse/ die "ssh" leaves IDENTITY_NAMES or the stem match goes ⇒ "THE NORNIR DEPLOY KEY WAS DESTROYED"

The uid test builds its tree inside the real $HOME — the one place every earlier fixture avoided, and the one place the guard behaves differently. It skips honestly (never silently passes) when run as root or without a $HOME.