This page explains why kuma works the way it does. Read it when a behaviour surprises you, or before you trust the machine with something important. If you want the commands, getting started walks the path. New words are in the glossary.
- What happens to changes you make by hand
- What updates itself, and what waits for you
- Boot health and automatic rollback
- Where the base system comes from
- What every image carries
- Why a desktop installs things you did not name
- kuma in your launcher
- What a build records: kuma.lock
- Why a file you edited by hand keeps winning
- Permissions, and a file kuma does not own
- Hibernation
- Backups, and the two things a restore needs
- What your machine trusts
- What a declaration does not reproduce
- What an install decides that a declaration cannot
- The nostr layer
You will install things without declaring them. That is fine. kuma calls this drift, and treats it as a suggestion rather than a mistake:
$ kuma diff
packages.flatpak
- org.gnome.Boxes installed, not declared (convergence removes it)
Ad-hoc flatpaks, kept as yours: io.github.kolunmi.Bazaar
→ kuma capture keep them: declare what this machine already runs
→ kuma sync converge now; otherwise the boot/daily run picks this upRead the example carefully, because the two lines mean different things. Boxes was in your declaration once and you removed it, so the next convergence uninstalls it. Bazaar is something you installed yourself, and kuma leaves it alone. Nothing is ever deleted just for being undeclared. Install apps from a store if you like; the cost of not declaring something is reproducibility, never survival.
Updates reach undeclared software too. Keeping what exists current is
kuma's job no matter who installed it, so a store app, an ad-hoc brew install, and a declared app are all updated on the same schedule. If you
want to stay on a version, use the ecosystem's own hold: flatpak mask
or brew pin.
kuma capture turns drift into a proposal against your declaration. It
shows you a diff of the file and writes nothing until --yes. It
gathers flatpaks and brew leaves, because those are the things you can
install by hand. It skips rpms (a bootc machine can't install one
imperatively anyway) and it never touches [user] or [system], because
a password hash must not walk into a file you commit.
Snapshots follow the same rule. kuma snapshot --restore <path> is a dry
run that says which snapshot a path would come back from and what it
would replace. It restores a path, never a whole subvolume: the accident
people actually have is one file.
Two kinds of software, two policies.
Flatpaks and brews update themselves. Convergence runs at boot and on a
daily timer, installs what your declaration names, and updates everything
present whether declared or not. The daily run waits for wall power and
an unmetered network first. If you want an app updated right now, update
it by hand; that is drift by choice, and kuma capture is how you would
keep it.
The system image is the opposite. An update replaces the whole operating
system and applies at the next reboot, so automating it would buy you
surprise reboots or a pile of staged updates you never booted. kuma stages
nothing you did not ask for. kuma update is yours to run.
What is automated is knowing when to run it:
kuma update --checkasks the repos what has moved since this image was built, security advisories first. Takes seconds, builds nothing.kuma doctorreports how old the booted image is and warns past a month. On Fedora, a month means at least one kernel you did not take.
warn deployment: booted image is 41 days old
→ kuma update recompose against the repos' current packages and rebuildOne change is loud enough to announce itself. If an update would move you
to a new Fedora release, kuma update says so in plain words before it
stages anything:
This is a Fedora release change: 44 to 45.That is the biggest change kuma can make to a machine, and in a package diff it would otherwise look like several hundred ordinary lines.
Every image includes greenboot. There is nothing to configure. A declarative system whose one bad update can strand the machine is not declarative where it counts.
Here is how it works. The first boot of a new deployment arms a rollback trigger with three attempts. A boot that hangs before the desktop comes up burns an attempt like any other failure. On a desktop image, a boot only counts as healthy once the greeter is actually on screen, so "boots into a black screen" is exactly the failure this catches. After three failed attempts, the machine boots the previous deployment on its own and makes that permanent. A bad update costs you three reboots, not the machine.
Three details worth knowing:
- There are no default health checks beyond the greeter. greenboot's own
optional package makes DNS resolution required, which is reasonable on
a server and absurd on a laptop booting offline. Add your own checks
under
/etc/greenboot/check/required.d/. - Machines installed before this feature existed are retrofitted on
every boot by
kuma-boot-health-sync, because the boot counter lives in bootloader config written at install time. - The boot menu names what it actually boots. ostree only rewrites an
entry's title when the kernel or its arguments change, so menus
otherwise keep stale version names.
kuma-boot-titles.servicereads each entry's title from the deployment it points at.
A rollback is never silent. The failed deployment stays in the rollback
slot, and kuma doctor reports the boot verdict and whether the
bootloader is counting correctly. And a deployment that used to work but
now fails reboots three times and then waits for a human, because rolling
back cannot fix a problem an update didn't cause.
The usual way to build a bootc image is to start FROM a big base image
and remove what you don't want. Without system.base in your
declaration, kuma does the opposite: it composes its own base from
Fedora's packages with rpm-ostree compose image, the same tool Fedora
uses to build fedora-bootc.
The starting point is Fedora's minimal bootc manifest, which is essentially kernel, systemd, bootc, and dnf. kuma adds what a real machine needs. What a real machine needs includes its operating tools: ping, ip, the ssh client, curl, and less ship in the base, so a fresh machine can diagnose itself and reach the world without declaring anything. Nothing is ever removed after the fact; the heavy stuff fedora-bootc carries for the general case simply never gets included. Fedora remains the package source for all of it. kuma builds no packages and no kernels.
The composed image is content-addressed: its name is a hash of the manifest that produced it. A build can therefore name its base before any compose has run, an unchanged manifest reuses the image already in storage, and a changed manifest cannot reuse a stale one.
Two consequences:
system.firmwareis the trim. If unset, the base ships every vendor's firmware, so a machine that declares nothing about its hardware still boots with working graphics, wifi, and audio. Name what your hardware needs and the rest stays out.kuma update --checkasks the repos, not a tag. A composed base has no upstream tag to check, and an update recomposes everything. So the check asks dnf directly what has a newer version and which of those carry security advisories.
Naming a base opts out of all of this: any bootc image can be one.
openssh-server is in the base and the image enables sshd.service by
name. This is deliberate: kuma vm and the smoke tests reach the guest
over ssh, and an image they could not reach would break the test harness.
That does not mean the world can reach it. The shipped firewalld zone does not open ssh at all. The one exception is a rule admitting ssh from 10.0.2.2, the gateway address of qemu's user-mode networking, because that is where the test machines connect from. (The side effect, stated in the zone's own description: a network numbered 10.0.2.0/24 could reach sshd too.)
Sign-ins use Fedora's defaults, so passwords work, and the account
kuma install creates is in wheel. But a stranger on the coffee-shop
wifi cannot get to the prompt. If you want to reach your own machine
over the network, open it deliberately:
$ sudo firewall-cmd --permanent --zone=public --add-service=sshOnline password guessing is capped: after 50 failed attempts the account
locks for a day. faillock --user <name> --reset clears it.
If you declare [user].ssh_keys, kuma serves them from
/etc/kuma/keys/<name> and never overwrites the user's own
~/.ssh/authorized_keys. To require keys outright, drop a config file
into /etc/ssh/sshd_config.d/. Files in /etc survive image updates,
and kuma doctor will report yours as a local modification, which is
exactly what it is.
[services].disable = ["sshd.service"] turns the unit off entirely.
sshd is a default, not part of the hardening floor, so your declaration
wins. Boot health, rollback, and the floor itself sit below that line
and cannot be switched off. Disable sshd and kuma vm still builds a
disk, but nothing can ssh into it.
Below the [services] line sits a set of security settings that no
declaration can turn off. It is taken from secureblue's audited
hardening, adapted to what kuma can test. Six files ship in every image:
/usr/lib/sysctl.d/70-kuma-hardening.conf(one file of kernel settings): programs may only debug their own children, kernel pointers are hidden in/proc, perf is root-only, kexec is disabled, SysRq is off, coredumps are dropped, address-space randomization gets the maximum entropy, and the machine no longer answers ping (icmp_echo_ignore_all;sysctl -wbrings it back for the session). The base ships ping for dialing out, but a kuma machine never answers one: ping silence is the floor working, not an outage. Probe a kuma machine with ssh orkuma doctor./usr/lib/bootc/kargs.d/05-kuma-hardening.toml: kernel arguments. Memory is zeroed on free (init_on_free, the one setting with a real performance cost, a few percent), page allocation is shuffled, legacyvsyscalland 32-bitvdsoare off, module signature enforcement is on, the initramfs offers no shell and halts instead of an emergency prompt,systemd's ssh keygen-on-boot is off, and the kernel does not trust the CPU's random number generator./etc/firewalld/zones/public.xml: the firewall zone described above./etc/chrony.conf: time comes from two independent vendors over NTS, an authenticated protocol, replacing the unsigned pool. Time matters more than it looks: certificate validation, log ordering, and kerberos expiry all depend on it./etc/NetworkManager/conf.d/kuma-mac.conf: wifi gets a random MAC per network, but the same one every time you join. The access point sees a different address on each network; your router's DHCP reservation follows the new address once./etc/security/faillock.conf: the lockout described above.
For completeness, what secureblue ships that kuma does not, and why: the
settings Fedora's kernel already defaults (like slab_nomerge);
io_uring_disabled, a real mitigation with a real list of things it
breaks; and lockdown=confidentiality plus mitigations=auto,nosmt.
The first would disable hibernation, which kuma sets up for. The second
halves your core count. Those are decisions for the machine's owner.
A bootc image is expected to be self-contained: everything the system runs is inside it. kuma's images also carry what a machine needs to understand itself:
- The declaration it was built from, verbatim, at
/usr/lib/kuma/kuma.toml. Comments and formatting intact. - kuma itself, at
/usr/bin/kuma. - The units and helpers that converge flatpaks, brews, the declared user, and boot health.
- kuma's signing key, plus a policy naming it for kuma's published
repository. This pair is in every image, not just published ones,
because the machine that needs it is the one installed from published
media, and that machine's
/etcis whatever the image it installed from put there. The rule covers kuma's own repository only; requiring signatures everywhere would refuse Fedora's base and your own locally built image.kuma doctorchecks the pair is present and working, because a signature nobody checks is a claim rather than a control.
That is the complete set for answering "what am I supposed to be" and
acting on it. A machine therefore needs neither a copy of your
declaration nor any tool you brought. kuma update --yes works on a
machine installed from an ISO that has never seen a kuma.toml, because
read-only commands fall back to the baked one. kuma init on such a
machine writes a copy true to that machine, not a generic starter.
Every image also carries FUSE 2, because AppImages need it. An AppImage
mounts itself as a filesystem before any of its own code runs, and
Fedora ships only FUSE 3. Without this, a downloaded file fails with
dlopen(): error loading libfuse.so.2 on a machine that is otherwise
complete.
The lock file is deliberately not included. A kuma.lock belongs in git
next to the declaration it pins, not on every machine built from it.
Two things to know about the baked declaration. It is world-readable,
because the probe and kuma init both need it, so a password_hash
line in your declaration can be read by any local user. (The hash that
actually creates the account ships separately, mode 0600.) And it
records what the machine was built to be, not what it is now. Comparing
the two is what kuma diff does.
Choosing a desktop installs packages you did not name. That set is session infrastructure: the parts that have to exist for a session to work at all. Applications are not in it, even convenient ones, because the two are reversible in different ways. Delete a line from your declaration and the next convergence uninstalls it. A package in a desktop set has no opt-out, so putting one there decides for every user of that desktop.
You can always add with packages.rpm. You cannot subtract from the
set.
Convergence removes exactly one thing you did not declare: a flatpak runtime that no installed application needs any more. Applications are never touched this way, only the shared platforms underneath them, and reinstalling one is a download rather than a decision. What a desktop contains lists both desktops in full.
Every desktop kuma builds puts kuma's own verbs in the launcher. Type
kuma into it and eight entries come back: edit the declaration, show
drift, review proposals, system health, check for updates, rebuild, roll
back, snapshots.
They are ordinary .desktop files in /usr/share/applications, so
whatever launcher finds your applications finds these too. No plugin,
nothing to configure. Each one opens a terminal, runs the verb, and
leaves the window open, because several ask for a password and all of
them print something worth reading. Press enter to close it.
Edit Declaration opens the declaration this machine is actually
using in $EDITOR (falling back to nano, vim, or vi). Which file that
is depends on where you run it: a kuma.toml in the current directory
outranks ~/.config/kuma/kuma.toml. kuma edit --print says which one
resolved without opening anything.
No entry writes your declaration without asking.
base = "…/fedora-bootc:44" names a tag, and tags move. One such move,
bootc 1.16.6 to 1.16.7 between two updates, is enough to break every
build that trusted the tag.
After your first build, a kuma.lock appears beside your declaration.
There is no verb to learn: kuma build reads it and refreshes it, and
kuma update is the only thing that moves the pin. Commit it.
The lock pins one thing and records another, on purpose:
- The base digest is enforced. Builds resolve
FROM name@sha256:…from the lock, so the same declaration plus the same lock builds from the same bytes anywhere. - Package versions are recorded, not pinned. Fedora's mirrors garbage-collect old builds within weeks, so a version pin would become a build failure unrelated to your declaration. The record exists to be diffed, and a lock can never break a build.
A composed base has no tag to distrust, so the lock holds its
content-addressed reference instead. A changed manifest then shows up as
a changed base, the same as editing system.base. The pin holds while
that image is still in storage; once it is gone, kuma says so and
composes a fresh one.
That makes an update legible. git diff kuma.lock is the whole story:
$ kuma update
base sha256:9f3ca81b2e4d -> sha256:a71b04ef9c33
bootc 1.16.6-1.fc44.x86_64 -> 1.16.7-1.fc44.x86_64
... and 34 more changed
rpm 36 changed, 2 added
$ kuma update --check
The base is composed locally from Fedora's repos (localhost/kuma-base:m26ccdd18fd07).
20 packages have moved in the repos since this machine booted its image.
kernel 7.1.7-200.fc44.x86_64 -> 7.1.8-200.fc44.x86_64 (important)
sqlite-libs 3.51.2-1.fc44.x86_64 -> 3.51.2-2.fc44.x86_64 (important)
linux-firmware 20260622-1.fc44.noarch -> 20260810-1.fc44.noarch (moderate)
ripgrep 14.1.1-4.fc44.x86_64 -> 15.2.0-1.fc44.x86_64
file-roller 44.1-1.fc44.x86_64 -> 44.2-1.fc44.x86_64
rpm 20 moved, 16 with security advisories (5 important, 11 moderate)For a named base, --check is one registry query and reports only
whether the base moved. For a composed base, every package is in play,
so the check asks dnf and sorts worst advisory first.
It answers about the running machine when there is one, so it does not
care how kuma was installed and needs no image in podman storage. Repo
metadata is cached under ~/.cache/kuma/dnf: the first run takes about
half a minute, the rest take seconds. No root either way.
One honest caveat: --check is a prediction. dnf reports what it would
upgrade, but a recompose runs its own dependency solve, which can add or
drop packages an upgrade query never sees. The lock diff afterwards is
the record of what actually happened.
On an ostree system, every difference between your /etc and the
image's defaults in /usr/etc is treated as a local modification, and
carried onto every future deployment. Edit a file by hand and it keeps
winning, silently, no matter what later images ship.
That is how ostree is designed to work, and it is a trap. You fix
something by editing /etc directly. Later you fix the same thing
properly in the declaration. The hand-edited copy keeps overriding it,
the declared version never gets tested, and nothing tells you why.
kuma doctor watches the files your image owns:
ok etc: 14 files this image owns in /etc, none shadowed locally
warn etc: local edits shadow the image: /etc/environment. These win over
every future image, so the declared version never applies
→ sudo cp /usr/etc/environment /etc/environmentThe cure is cp, not rm: a deletion is itself a local modification
and carries forward as one.
The same merge has a second edge, and it decides where machine state
can live. A file the image ships lands in /usr/etc, not /etc. If a
later image drops that file, the merge drops it from /etc too.
Anything you expect to survive updates therefore cannot arrive as image
content in /etc.
That is why kuma install writes the account it asks for to
/var/lib/kuma/user and not /etc/kuma/user. bootc fills /var from
the image once, at install, and never touches it again, which is
exactly what install-time answers need. The hostname has the same
problem and the opposite cure: /etc/hostname is image content, so
the installed machine writes it at first boot from
/var/lib/kuma/hostname, and writing it is what makes it a local
modification, which is what survives.
So the rule has three parts: /usr is the image, /etc is machine
state the image also has an opinion about, and /var is machine state
it does not.
There is deliberately no kuma capture for this. Package drift is
yours to keep because a package is your choice. /etc content is
kuma's curation, so an edit worth keeping belongs in the image itself.
A flatpak's permissions are machine state that outlives every rebuild.
They live in override files under /var/lib/flatpak/overrides and
~/.local/share/flatpak/overrides, and they survive image updates the
way everything in /var does. Before they were declarable, a machine
could carry a permission nobody could find again.
[overrides] declares them, per app:
[overrides."app.zen_browser.zen"]
filesystems = ["home", "!~/.ssh"] # keep home, take the keys away
sockets = ["wayland"]
environment = { MOZ_ENABLE_WAYLAND = "1" }The shape is flatpak's own override file, so flatpak override --show
reads back into it, and a ! in front of a permission takes it away.
kuma owns keys, not files. Flatseal writes the same files, and so
does anyone running flatpak override by hand. Convergence therefore
sets the keys you declared, removes the keys it set that you stopped
declaring, and copies every other line through untouched. A key kuma
never set is never kuma's to delete, even when it sits in the same file
and contradicts what you declared. Declaring is how you win that
argument; kuma diff is how you find out you are having it.
That makes Flatseal the editor and your declaration the record, and they
meet at kuma capture:
$ kuma capture
Would declare in ~/.config/kuma/kuma.toml:
+ org.chromium.Chromium [overrides] user filesystemsCapture offers permissions only for apps your declaration already installs. Machines accumulate override files for software that left years ago, and proposing those would be proposing rubble.
Declared permissions are restored at boot and when you run kuma sync,
and never by the daily timer. An app arriving at a random hour is
harmless. A permission reverting at a random hour changes what a
running program can reach, and a toggle that silently flips back
tomorrow afternoon is indistinguishable from a bug. The rule: boot
restores what you declared, and the session in between is yours to
experiment in.
Flatpak keeps two override stores, and kuma writes both. scope = "user" writes the per-user one, applied by a systemd --user unit
rather than by root reaching into a home. One app declares into one
store: flatpak merges the two with the user store winning per key, so
an app declared in both would be a file where half your lines quietly
lose to the other half.
A machine with a swapfile can hibernate: memory is written to the file
and the machine powers off, and the next boot resumes where you were.
If you did not ask for hibernate at install time, kuma hibernate sets
it up:
$ kuma hibernate # what it would make, and where
$ kuma hibernate --yes # make it; takes effect on the next boot
$ kuma hibernate --off --yes # take it away againThe file defaults to the size of your memory, which is the most a machine can ever need to save. It is never resized in place: growing it would move it, and the kernel would then resume from the wrong place on the disk. To change the size, turn it off and on again.
Setting hibernate up also points the lid at suspend-then-hibernate: a laptop closed in a bag suspends first, then hibernates on the firmware's low-battery alarm, before the battery dies.
Why this cannot live in the declaration: the kernel has to be told
where the swapfile physically sits on the disk, as a block offset, and
that number describes one file on one disk. Two machines built from the
same declaration would need different values, and a machine given the
wrong one would hibernate, power off, and boot fresh with the session
gone. So the size is asked at install, and kuma hibernate asks on a
running machine. kuma doctor compares the offset the kernel was given
against the one the file actually has, because that is the only way
this breaks quietly.
Two limits. First, Secure Boot and hibernate do not go together: a
kernel booted with Secure Boot on runs locked down, and a locked-down
kernel refuses to hibernate, because a hibernate image is a way to
write arbitrary memory back into a running kernel. kuma doctor warns
rather than calling the machine ready. Second, hibernate from the
desktop, not over ssh: logind gates it on an active session, and an
ssh login is not one. That is systemd's rule, not kuma's.
[snapshots] answers a mistake. It cannot answer a dead disk, because
the copies are on the disk. [backup] sends them somewhere else, to a
restic repository:
[snapshots]
enable = true
[backup]
enable = true
repo = "s3:https://minio.example:9000/kuma"
secret = "backup"Backups copy from a snapshot, never from the live subvolume. That
is why enabling backups requires enabling snapshots, and why kuma check says so rather than letting you find out at 3am. A backup taken
while files are being written is a backup of a moment that never
existed.
The credential is named here and held on the machine, at
/var/lib/kuma/secrets/<secret>.env, mode 0600, put there by hand. A
declaration is written to be committed, and gets baked world-readable
into every image built from it. That is the wrong place for a secret,
and the same boundary that keeps password_hash out of kuma capture.
That is not a hole in the file. Naming the credential keeps the declaration complete: the fact that one exists and what it is called are both declared, and only the value is elsewhere. The practical consequence: restoring a machine needs two things, this file and that credential. A repository address carrying its own password is refused, because nobody decides to put a secret in git; they paste a restic command line that already worked.
What the far end can see. restic encrypts everything on your machine before sending it. The repository holds ciphertext its operator cannot read. What they can see is the shape of your traffic: how much you store, how often, and when.
Excludes are additive on top of a set you cannot remove: linuxbrew,
~/.cache, ~/.local/share/containers. Every one is a tree this same
declaration rebuilds.
One thing outside home matters more than anything inside it, and it is off by default:
network_connections = true # carries /etc/NetworkManager/system-connectionsThose files hold a wifi password per network, in the clear, and nothing
else can recreate them: not this declaration (they are out of scope),
not the image (it ships the directory empty), not home (they live in
/etc). Restore without them and every network password gets retyped.
They are off by default because turning them on moves secrets off the
machine, and that should be your decision. kuma doctor names which
way it is set on every run.
The first copy is your whole home rather than a day's difference, so it is a command rather than something a timer starts:
$ sudo kuma backup --initThe failure this feature actually has is silence. The unit exits
cleanly with no credential, no snapshot, or no repository, all three on
purpose, so "last run succeeded" would be true of a machine that never
copied a byte. Only a run that copied something writes the stamp, and
kuma doctor checks the stamp, not the unit. How stale is too stale
follows the interval you declared, so a monthly policy is not called
unhealthy on day eight.
A backup nobody has restored is a claim. This is the other half, and it is the reason the feature exists.
Boot the installer media and point an install at the repository:
$ sudo kuma install --disk /dev/nvme0n1 --restore recovery.envrecovery.env is one file holding the repository address and its
credentials. It carries RESTIC_REPOSITORY as well as the keys,
because the machine being restored has no declaration yet. Put it on
the stick beside the ISO and a dead disk needs nothing else typed. The
install refuses to touch the disk if it cannot open the repository,
since the old machine may still be the only copy.
Write the values as plain text. A value containing $, a backtick,
a quote, or a backslash is refused, by kuma install --restore and by
kuma backup alike. Two things read this file, the first boot through
systemd and the verb through a shell loop, and they disagree about what
those characters mean. A password containing one would be a different
password depending on which reader opened the repository. kuma will not
guess which you meant. If a repository predates kuma 0.17 and its
password contains one of those characters, change it with restic passwd before rewriting the file: it was encrypted with the expanded
value.
The install does not restore anything itself. It writes the request
and the credential onto the new machine. The first boot does the work,
after the unit that gives /var/home its own subvolume has run and
after your account exists. That ordering is forced: /var/home does
not exist at install time at all.
If the repository cannot be reached on that first boot, the request stays and the next boot tries again. A bad day costs a retry, not the data.
Two things to know before you need this. The image you install must
have been built from a declaration with [backup], since that is what
carries restic and the restore unit; installing your own image is the
normal case. And restoring needs two things: this file and the
credential it names.
Every TLS connection the machine makes is checked against a set of certificate authorities. Adding to that set is one of the most consequential things a declaration can do, so it has a section:
[system.ca_certificates]
"my-root-ca" = """
-----BEGIN CERTIFICATE-----
MIIBkTCB+wIJAKZ...
-----END CERTIFICATE-----
"""The certificate goes in the file rather than beside it. A declaration that points at a path elsewhere is not one file any more: carry it to a second machine and the anchor is missing, with nothing to say so.
Inlining is safe here for a reason that does not generalise. A CA
certificate is public by construction. A private key is not, so one
pasted into this table is refused, not warned about: it would otherwise
be baked world-readable into every image built from the file and pushed
to a registry. A value that is not a certificate at all is refused by
kuma check for the ordinary reason.
The anchor is copied to /etc/pki/ca-trust/source/anchors/, and
update-ca-trust runs in the same build layer that adds it, rather
than being left for a boot to remember. Landing under /etc also puts
it inside the etc check described above, so a local edit that shadows
it gets reported.
A machine built from your declaration carries state the file never mentions. Some of it deliberately so. Everything below survives an image update and is outside the declaration on purpose:
Your data. /var/home is yours. A declaration rebuilds a system,
not a home. [snapshots] covers a mistake; [backup] covers a dead
disk.
Machine identity. SSH host keys, the machine ID, the disk layout in
fstab and crypttab. These are what make this machine this machine.
Copying them onto a second one would be a bug.
Secrets. Network connections live in
/etc/NetworkManager/system-connections and hold the wifi password in
the file. A declaration is baked world-readable into every image built
from it, which is why secrets stay out.
Per-app and per-desktop state. dconf and GSettings, the portal permission
store under ~/.local/share/flatpak/db (the location and notification
prompts you answered), device pairings, and units you enabled in your own
systemd --user manager. [services] is system scope only.
Display settings. Mode, scale, and position belong to this machine's
monitors, not to the system; two machines built from one declaration have
different screens, the same reason timezone stays out. The shell applies
your changes live and persists the difference to
~/.config/niri/local.kdl, the delta file the image's niri config
includes last, so your choices win over the image's defaults without ever
shadowing them.
Everything else in /etc the image never shipped. kuma doctor
watches the files this image owns and says nothing about the rest. A
real machine carries dozens of legitimately local files, and a check
that lists them all is one you learn to scroll past. Two shapes are
always wrong rather than merely undeclared, and doctor reports both: a
flatpak override pointing at nothing, and an enabled unit with no unit
file behind it.None of this is a to-do list. Some of it is a boundary that will not
move (identity, secrets). Some of it is a decision that could (dconf,
the portal store). None of it is an oversight.
Everything above is about images. An install is where an image meets a machine, and the two know different things.
An image knows what should be installed. It cannot know who the machine
is for: a published image is pulled by strangers, so it declares no
[user], and a machine installed from one would have no account and no
way in. So kuma install asks, writes the answers to
/var/lib/kuma/user on the target, and kuma-user-sync creates the
account at first boot, exactly as it does for a declared one. The
installer creates nobody; it writes down what the machine should
converge to. If the image does declare a [user] and you ask for a
different one, the installer also drops the declared account's autologin
from the greeter, since a greeter cannot log in a user the machine will
not have.
That split decides where things live, for the reason in why a file you
edited by hand keeps winning:
/var is filled from the image once and never touched again, which is
what install-time answers need. A file the installer shipped into /etc
would be image content, and the next update would delete it.
The disk itself is machine state. kuma writes the same three partitions
every time: an EFI system partition, a /boot outside the root, and a
root that takes the rest. /boot is separate even when nothing is
encrypted, so that turning encryption on changes what the third
partition holds, not the shape of the disk. The sizes of the first two
are asked at install, with defaults shown, because they cannot be
revised without installing again. The shape is not asked, because a
machine built from one image should not have to know how to boot four
kinds of one. Whether the root holds a LUKS container is asked at
install too, for the same reason.
What encryption protects is a disk at rest, and only that. /boot
and the EFI partition sit outside the container on every install,
because a bootloader has to read a kernel before anything is unlocked.
Nothing measures or verifies them, so an attacker with repeated
physical access can modify the initramfs that later asks for your
passphrase. Defending against that needs Secure Boot with measured
boot, which kuma does not do. Encryption answers a stolen or discarded
disk, not a machine somebody keeps visiting.
The passphrase is handled like the machine's most consequential
secret, because it is. It reaches cryptsetup on a pipe, never a
command line where ps would show it, and never a file. It does live
in memory while the install runs, and kuma makes no claim to defend
against something reading another process's memory, which on this
machine already means root. kuma writes it nowhere. There is no
recovery key and no escrow: a lost passphrase is a lost disk. Changing
it later is cryptsetup luksChangeKey on the machine itself.
The account password gets the same handling: prompted at a terminal or
read as one line from a pipe, never passed as an argument, so it does
not reach ps or shell history. Its hash is written to
/var/lib/kuma/user on the target, mode 0600, where kuma-user-sync
reads it at first boot. Unlike a declared [user], it is written to
the machine rather than baked into the image, so publishing the image
does not publish it.
A first boot then spends a few minutes on the rest: the account is
made, the hostname applied, and the declared flatpaks and brews
downloaded, about a gigabyte for a full desktop. kuma says so while it
happens: bare kuma reports converging rather than drift, and offers
no sync, because a sync is what is already running. A machine that does
not match its declaration yet is not a machine that has stopped trying.
None of this is in kuma.toml, and none of it should be. Two machines
installed from one declaration can have different disks, different
names, and different people. The declaration says what the system is;
the install says whose it is.
The [nostr] layer runs a bunker: a small daemon that holds a nostr
signing key and signs for the apps you pair with it, with every request
governed by a policy engine and every decision logged. It has its own
page: the nostr layer covers setup, the trust levels, where
the key lives, and what a relay operator can and cannot see.