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
uninstallALWAYS 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:
- The uid, not just the root.
purge_service_data_keep_keys(home, spec.user)resolved the owning uid from the service account name (to_systemd_specfalls back to the app name), never the invoking human.guard_chown_target_inthen refused every child inside the invoking user's home —Guard(InvokingUserHome { home: "/home/rickard" })— givingwiped=0 kept=1, data intactfor all four apps while the CLI printed ✓. - Every fixture avoided the one place it mattered. All of them built their trees in a tempdir outside
$HOME, which is precisely where the guard behaves differently. That is why no test caught it.
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:
sshis identity, dot or no dot.- Rotation/backup spellings are identity. Exact matching kept
id_ed25519while erasingid_ed25519.nornir-t14s.bakbeside it — still a destroyed key from the operator's point of view. Hence the stem match.
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:
- the plain
Lifecycle::uninstall()always passesUninstallOptions::default(); guarded_remove_dir_allhard-refuses any.sshcomponent, always;- the ONLY code path that deletes key material is
systemd::wipe_identity_material, which keeps every other protection (system dirs, foreign homes, the invoking human's$HOME, the depth floor, symlink refusal) and additionally makes containment in the declared root mandatory.
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.
is_noop()— the one predicate a CLI must consult before printing anything.summary()— renders a no-op as✗ uninstall <svc>: NOTHING was removed, plus every note explaining why. It cannot print ✓ over a no-op.
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.