Removed-game cleanup¶
When RomM stops serving a ROM id, the plugin keeps the local row. Nothing is ever deleted automatically — a library sync that no longer sees an id retains everything it already has. Removing that local state is an explicit, separately confirmed operation, and this page owns how it stays safe.
The five prune invariants in the repo's CLAUDE.md register are one-clause statements of the rules below; this page is
where their detail lives. Save-side path resolution and quarantine mechanics belong to
save-file-sync-architecture.md; shortcut identity and artwork belong to
steam-non-steam-shortcuts.md.
Where the code lives¶
| Module | Responsibility |
|---|---|
services/prune/service.py |
Callable facade and the ephemeral per-run state |
services/prune/preview.py |
Builds the candidate preview (sizes, groups, warnings) |
services/prune/registry.py |
Discovers candidates from local rows and fetch generations |
services/prune/recovery.py |
Sequences bundle creation and sealing |
services/prune/executor.py |
Sequences a confirmed group's phases and arms its recovery bundle |
services/prune/planning.py |
Decides what a group would do, or the reason it is refused |
services/prune/liveness.py |
Namespace-bound exact-ID proof — the only deletion authority |
services/prune/steam_actions.py |
Requests the frontend's Steam mutations and reads their outcome |
services/prune/finalize.py |
Revalidates every proof, then runs the irreversible cascade |
services/prune/save_locks.py |
Holds save locks over an ownership set proven stable under them |
services/prune/results.py |
Shapes progress/completion frames and terminal group results |
services/prune/requests.py |
Parses and validates the wire payloads |
lib/prune_gate.py |
The admission gate — reservations, conflicting-callable refusal, leases |
adapters/recovery_bundle.py |
Writes, checksums, seals and publishes a recovery bundle |
adapters/steam_recovery.py |
Captures Steam-only state; edits the controller value in localconfig |
adapters/descriptor_paths.py |
Descriptor-relative, no-follow claim capture and claimed mutation |
Deletion authority¶
Only a RommNotFoundError from a fresh, single-attempt, exact-id request authorizes deleting anything. A live response,
a wrong or malformed payload, a timeout, a transport or auth failure, a 5xx, a cancellation, or an unknown exception all
retain local data. Liveness is proven before work begins, again around Steam actions, and once more immediately before
local finalization, so a source that comes back to life — or a replacement that disappears — stops that group.
A dropped ROM does not 404 the saves endpoint; GET /api/saves?rom_id=<dead> answers 200 []. Only get_rom
distinguishes a dead id, so every liveness probe goes through it.
The 404 has to come from RomM¶
A 404 is only evidence that a ROM is gone if the thing answering is RomM and the request reached the ROM route. RomM is
FastAPI, so a reverse proxy with a wrong path prefix makes RomM itself answer a clean JSON 404
({"detail": "Not Found"}) for every id — including ids that plainly exist. No body-shape check distinguishes that
route-404 from an entity-404, and one request at a time neither can a human. Believed, it is also amplified: the run
probes a group's live siblings too, so a misroute turns "one version vanished, repoint to the other" into "the whole
game is gone", taking the Steam shortcut and any unselected installed content with it.
So no 404 is honoured unless the same round holds positive proof, recorded as the tier that supplied it:
| Tier | What it is |
|---|---|
still_there |
A probed ROM answered 200 with its own id — the round already proved the route |
canary_rom |
A known-live id answered 200 with its own id |
canary_user |
No known-live id exists; the authenticated user matched the pinned namespace |
none |
Nothing vouched — every 404 in the round is unconfirmed and nothing is removed |
still_there is free. Any 200-carrying-the-right-id came from the very route whose 404s are in question, so groups
with a live member — every repoint case — pay nothing.
canary_rom is the dedicated check, and it deliberately rides the same route family rather than a health or version
endpoint, which a path misroute leaves working. Its subjects are ids the last complete fetch is recorded as returning:
the best available liveness prior, and by construction never candidates. One 200-with-its-own-id proves route, auth and
server at once. At most two are asked — a retry, because the first can genuinely have vanished since the sync, and no
more, because this is a check rather than a survey.
canary_user is the weaker fallback, reached only when the library holds no known-live id at all (an essentially
empty library). It shows the server is RomM and the token still belongs to the pinned user, but says nothing about the
ROM route, which is why it is last and why the tier is written down.
A control that 404s is a proof failure, not a licence to keep looking. Every 404 in the round becomes uncertain
with reason unconfirmed_server, the group is skipped, and the message says the server's answers could not be confirmed
— the same outcome whether the route is broken or the control genuinely vanished, because from here those are the same
observation.
Proof is per round and never carried over, for the same reason the re-proof rounds exist at all: a route can start misbehaving between them. The tier is logged with the run id, so which authority a run acted on is readable after the fact rather than inferred from what survived.
This is a precondition on top of the 404 rule, never a softening of it: deletion authority is still, only, a fresh single-attempt exact-id 404 under the pinned namespace.
What it does not cover. The control and the candidate are different ids by construction, so a per-id misroute — one that answers correctly for the control and a bogus 404 for the candidate — passes every check here. Nothing client-side can close that: a client cannot distinguish "this id is gone" from "this id, specifically, was misrouted" without a second source of truth about that id. What the ROM tiers rule out is the whole-route failure, which is the shape a proxy misconfiguration actually takes.
How this composes with the adapter-side 404 discrimination (#1622): the two run in series and are not redundant. The
adapter filters non-entity 404 shapes at the transport boundary, so a 404 that does not look like RomM answering
about an entity never reaches this layer as a RommNotFoundError. The canary demands endpoint proof per round,
regardless of shape. A misroute that answers an entity-shaped JSON 404 passes the adapter untouched, so the canary
remains the sole defence against it.
Server namespace binding¶
A run pins the RomM namespace it discovered its candidates under: canonical server origin, token origin, and RomM user id. A response that arrives after any of those changed can never authorize deleting a row discovered under the earlier namespace — a namespace change is uncertainty, not a 404.
Discovery¶
Bulk discovery from the Danger Zone is generation-gated: a platform with no known completed fetch produces no candidates at all, because absence from an incomplete fetch is not evidence of removal. Inline removal from an already-vanished version-picker row needs no generation — the row is already known vanished — but still takes the same fresh exact-id proof.
The preview discloses every member of an affected group, not only the candidates. A member carrying the platform
stamp's current fetch generation is not evidence that RomM still serves it: whole-game removal is decided by the run's
fresh probe of every id in the group, never by the stored generation, so a generation-current row can still be taken and
may never be deleted unseen. What the generation does establish is that the row was there at the last completed fetch,
so the projection separates the two classes rather than flattening them — candidates sort first, the page carries a
candidate_total beside total, and the dialog leads with the candidate count and labels the rest as retained.
The wire always carries both classes; which ones are rendered is a frontend decision keyed on the whole-game removal
option, because selected_prune_ids returns a non-candidate only under that option. With it off, the retained rows
describe an outcome that cannot occur, so the dialog hides them and drops any installed-content selection they carried;
every page is still fetched, and the completeness gate before confirmation is unchanged.
Cleanup deliberately does not clear the platform's platform_sync_state completion stamp. It does not need to: a
server that dropped ids also reports a different rom_count, and the fetcher's existing stamp-count guard already
forces the re-fetch. Clearing the stamp would cost the platform its incremental skip and disable further bulk discovery
until a new complete fetch landed.
Admission and conflicting operations¶
A prune claim excludes library sync, downloads and resumes, migrations, version switches, core/disc/controller writes,
launch evaluation, save mutations, session writes, uninstalls, connection identity changes, and affected cache cleanup.
Each conflicting callable registers its own activity before its first await, and detached work retains that
registration for the task's lifetime.
Admission is atomic in the part that matters: prune_exclusive_start takes the gate lock, refuses if any conflicting
callable is registered, and reserves the prune claim — all in one lock hold, so no registration can slip between the
check and the reservation. The run then proceeds without the lock. The reservation alone already refuses every
conflicting callable, and holding the lock across the run's preview rebuild would make Play, save status, and downloads
wait for that rebuild rather than learning their verdict immediately.
Every claim on the gate is named. A callable registration carries the callable's own name, detached work carries the name of the callable that spawned it, and a lease carries its acquisition key plus the time it was taken. A refusal logs the complete holder inventory at INFO — label, kind, age, and a lease's remaining time — and names the holder in the refused message itself when its key has a user-facing name, falling back to the generic text rather than putting an internal token in front of the user. Acquire, renew and release are logged at debug; a lease that reaches its deadline is logged at INFO instead, because an expiry means its owner never released it. Without this a blocked cleanup is indistinguishable from a plugin that has stopped responding, and the holder cannot be identified after the fact.
The live-claim count is derived from the holder registry rather than tracked beside it: a counter that can drift from the registry is exactly what made an unexplained refusal unattributable.
Frontend-owned Steam work spans many calls, so it holds a globally registered, bounded, tokenized lease that it
heartbeats through every sibling continuation's final write — including each paced sync_stale removal and the terminal
repoint publication. Every continuation re-checks its abort signal before each later Steam mutation. Failed event
delivery releases an unreachable token. The owner's plugin generation is captured before each backend wait, and teardown
tombstones it so a late lease-bearing response is released without doing work; only a genuine remount opens a new
generation. Teardown stops renewal and blocks future writes but defers the explicit release until already-started Steam
promises settle.
A frontend that has just mounted disowns every lease outstanding at that moment, once, before anything else can acquire one. A continuation whose JS context is torn down mid-call — the double mount at plugin load does this — never reaches its release and never renews either, so its lease pins the gate for a full TTL with nobody behind it. A fresh mount is the proof that no earlier continuation survives, which makes it the one moment such an orphan is provably safe to drop; run claims and callable registrations are untouched, because only the frontend's own leases are the frontend's to disown. Each one released this way is logged at INFO, since it means a leak happened.
A lease is the frontend's to release, so every path that receives one must give it back — including the paths that do no work. A terminal completion frame carries a publication lease whenever the run committed a repoint; when the frame turns out to have nothing to publish, the listener releases it immediately rather than letting it pin the gate until its TTL. The TTL remains the backstop for the one case the frontend cannot cover: a response lost in transit carries a token the frontend never learned, and the expiry log is what makes that visible.
Actions and frames¶
A frontend action mutates Steam only after atomically claiming its exact run, token, discriminant, and binding. Identical repeat claims are idempotent, action delivery is serialized and deduplicated, and a completion retry never repeats the Steam operation. An outcome that was claimed but lost in transit is reported as ambiguous — never as success — and the local rows are retained for reconciliation against live Steam absence.
Every action, progress and completion frame carries the preview ID it originated from. A pending frontend preview may adopt only a matching run — including the case where the run started but its success response was lost — and foreign, stale, duplicate or post-terminal frames trigger no state change and no side effects. Completion finalizes only from a contiguous chunk set, and an accepted terminal result seals the run against every later frame. Payloads are tokenized and byte-bounded; warnings distinguish entries that were omitted from text that was merely shortened, and stay visible even on an otherwise successful run.
If a completion chunk set never completes, the frontend does not stay wedged: a staleness timeout clears the stalled progress, surfaces a "result was lost" warning, and re-enables the entry point so the user can re-scan.
Filesystem safety¶
Every prune source carries a claim before it is touched: the root's device, inode, mount id, mode, size, mtime and ctime, every descendant's identity, and every regular file's hash. Mutation is descriptor-relative and no-follow throughout, and a nested mount transition — including a same-device bind mount — fails closed. A path re-lookup alone never authorizes a delete or a quarantine.
What the hashes are for, and where they stop¶
Why the disciplines are split this way, and what was rejected on the way there, is ADR-0027.
A claim's regular-file hashes exist to bind a deletion to bytes held somewhere else: the sealed bundle's
checksums.sha256 and its per-artifact digests are the same values, and _require_records_match_claims refuses any
artifact record whose digest differs from the claim's, so consuming the claim proves the copy in the bundle is the copy
being deleted.
Where there is no second copy, that binding has nothing to attach to. Re-reading the bytes then only compares them against themselves, and exact identity (device, inode, mode, size, mtime, ctime) plus the kernel writer exclusion — which cannot be established at all while another process holds the file open for writing — already carry everything the comparison could. So the discipline follows the bundle, not the caller:
| Source | Claim |
|---|---|
| Selected for a sealed bundle | Decoded from that held bundle, digest-bound |
| Not captured, but a bundle was sealed | Sealed fresh at mutation time, content-bound — the bundle exists |
| Removed with recovery off (no bundle anywhere) | Sealed fresh at mutation time, identity-only |
| Removed by a user-initiated uninstall (no bundle) | Sealed fresh at mutation time, identity-only |
An identity-only claim is claim_source(..., digest=False) and records itself as content_bound: false. Everything
else holds unchanged: staging rename, writer exclusion, mount checks, no-follow traversal, and exact-identity
revalidation immediately before each unlink. The cost of not drawing this line was measured — hashing turned a 31 GB
uninstall into roughly 23 minutes of reading, four times over
(#1664).
Earlier revisions of this page described the last two rows as taking a "presence-or-absence claim". That was never what
the code did — those sources took a fully content-bound claim, and the phrase described only the provenance of the
claim (sealed here rather than decoded from a bundle), not its strength. An identity-only claim is weaker than what
shipped and far stronger than that phrase suggests, so the table above states the strength outright. The saves side is
unaffected: exclusive-save claims stay content-bound, and validate_prune_absences reads only their exists flag when
it rechecks the quarantined set before the cascade.
An identity-only claim is also the only one allowed to adopt interrupted staging. Where the source is absent, the
parent still holds a .{basename}.romm-prune-* entry, and the install record survives to prove the path was this ROM's,
the next attempt finishes the removal under a fresh self-claim — a claim it can simply re-seal. A bundle-backed removal
cannot: its authority came from a seal that a partially consumed source no longer matches.
Writer exclusion: whole-tree versus per-unlink¶
A content-bound removal leases every regular file in the tree before it deletes anything and holds all of those leases until the last unlink. That makes it all-or-nothing: a writer anywhere in the tree refuses the removal with nothing deleted and the tree renamed back.
An identity-only removal of a directory cannot do that. One open descriptor per file, against a soft RLIMIT_NOFILE of
1024, made a tree of a few thousand files fail with EMFILE — and because the rollback then restored it intact, those
ROMs were permanently un-uninstallable through the UI. Multi-thousand-file dumps are ordinary on some platforms. So
it leases each file only for its own unlink, and the guarantees split:
- Unchanged — every unlink is authorized by exact identity revalidated immediately before it, under writer exclusion held across that unlink. A single file that another process has open for writing is never deleted.
- Unchanged — a whole-subtree pass runs before any unlink and takes each file's lease in turn, so a writer already holding any file in the tree, or any identity drift, still refuses with nothing deleted.
- Changed — a writer that arrives during the unlink loop yields a partial removal instead of a clean refusal. It is reported as partial and ambiguous, never as success, and the message names how far the removal got ("3 of 331 files were removed", or that no file was removed). The remainder stays under the staging name, which the next attempt reclaims.
A single-file source keeps the whole-hold form under either discipline — there is only one descriptor to hold.
- Regular-file and controller-claim deletion holds kernel writer exclusion from final validation through the unlink; if exclusion cannot be established the source is retained. A writer-exclusion teardown fault is ambiguity, not success. Whether that hold spans the whole tree or one file at a time depends on the claim — see Writer exclusion.
- Selected sources consume claims decoded from the same held, digest-bound sealed bundle. Every other source seals its own claim at mutation time, content-bound while a bundle exists to bind it to and identity-only when none does — see What the hashes are for.
- Every exclusive save is expected absent after quarantine, and the whole set is rechecked collectively immediately before the aggregate cascade — a save an emulator recreated in between stops the deletion.
- Quarantine publication is atomic no-replace, so a concurrently created
.romm-backupdestination is never overwritten. - Controller-claim branches revalidate and preserve a newer held-inode edit at a surfaced path.
- Unsafe recovery-failure cleanup preserves the anchored staging path and reports it.
Steam's localconfig.vdf is parsed with the duplicate-key-preserving mapper and only the one relevant per-app
controller value is touched. Valve's format permits duplicate keys, and a plain-dict round-trip would silently collapse
unrelated user data on rewrite. Cleanup never edits shortcuts.vdf.
Sizing is not hashing: the preview measures installed content with a size-only, descriptor-relative, no-follow
traversal. Hashing a multi-gigabyte ROM tree to fill in a preview row — before the user has confirmed anything — would
stall the Danger Zone for minutes. A path whose size cannot be measured reports installed_bytes: None, carries a
warning, and cannot be selected for recovery.
Recovery¶
Recovery is a temporary per-run choice, not a persisted setting. The confirmation dialog offers Create recovery bundle (default on), and Include installed ROM content per candidate (default off). Whole-game removal also defaults on: removing a game the server no longer has is the operation this dialog exists for, and the default-on bundle is what keeps it reversible by hand. The two defaults are a pair — turning recovery off requires a separate acknowledgement toggle before the run can start. The dialog shows recursive size per ROM alongside required and free space, and blocks confirmation when space is insufficient.
The root is ~/<package-name>-recovery, with the package name taken from package.json through the canonical metadata
adapter and path-sanitized (today: ~/decky-romm-sync-recovery). Reading free space must not create that layout — a
read-only preview stats the nearest existing parent, and the directories appear only when a bundle is actually written.
The root's own README.txt, which explains what the folder is, is written by the same layout-creating step for the same
reason: the only moment the root is known to be wanted is the one that creates it. It is best-effort, because a bundle
must never fail to seal over its folder's signpost.
A bundle records the complete pre-cascade state in lossless JSON: the ROM aggregate, install and metadata state,
save-sync baselines and files, playtime including pending sessions, completion stamps, plugin artifacts, and applicable
Steam-only state. Every exact attributable current save is copied and checksum-verified, and existing .romm-backup
history is copied in while remaining at its original location. Bundles are sealed, checksum-verified, descriptor-bound,
and published atomically under bundles/.
The human layer¶
There is no restore UI, so the folder itself is the restore interface and is built to be read by a person months later.
That layer is presentation over the sealed machine layer, never a change to it: files/NNNNNN, manifest.json,
checksums.sha256 and SEAL.json are what the run's own claim consumption is digest-bound to, and they keep their
shapes.
- Folder name —
<sanitized game name>_<YYYY-MM-DD>_<short id>, named after the row the run is removing rather than the group's lowest id, so a bundle is never titled after a version that survived. Uniqueness rides on the short id; the name is bounded and path-sanitized, and the seal refuses to overwrite an existing directory regardless. The folder name is not a rename — it is thebundle_idthe seal is written with, soSEAL.json's basename binding holds by construction. README.txt— a generated index: every ROM id with its name, file name, platform and role in the run (removed, or kept and recorded for context); everyfiles/NNNNNNblob with its artifact kind in plain words, its size, and the absolute path it must be copied back to; playtime in whole units beside the game's name; and step-by-step manual restore instructions starting with the checksum verification. It is rendered inside the seal, because the blob mapping it indexes only exists once the artifacts have been copied.playtime.txt— keeps its exact machine-readable fields, with the game's name beside each ROM id.
For a fully vanished game whose shortcut will be removed, an enabled bundle also captures Steam artwork and per-app Steam Input files, the appId, name, executable, start directory, launch options, collection membership, available Steam playtime, and the relevant controller-config value. No artwork base64 crosses the Decky bridge.
Failure is never rewritten into success. Seal, rename and cleanup durability failures surface as exact or ambiguous partial mutations. If a bundle cannot be proven durable it is renamed aside rather than published; if that rename also fails, the reported message names what actually remains on disk rather than claiming a preservation that did not happen.
There is no automatic restore. A bundle is machine-readable and documented for future or manual recovery.
Saves¶
Exclusive current saves leave the emulator directory only through the existing .romm-backup quarantine funnel. A save
path shared with any remaining local version — installed or not — is copied into the bundle but never removed.
Unknown or unsafe-to-resolve save locations are recorded, left untouched, and do not block pruning the row.
Save states are entirely untouched and remain unsupported. Extending save-state support must extend this recovery contract before purge is allowed to remove state files.
A bound vanished row with unsynced saves may bypass the normal save-stranding guard only after its recovery bundle sealed successfully. With recovery disabled or failed, the group is skipped instead.
Steam-side outcomes¶
Repointing a bound vanished shortcut preserves the shortcut's appId, collections, Steam-side playtime, and identity, and confirm-writes the exact new launch command. The replacement chosen is exactly the version picker's natural live Default. A group with multiple bound shortcuts is skipped unchanged.
Removing a fully vanished game's shortcut requires a claimed action and live Steam confirmation. An attempted but unconfirmed removal is reported as ambiguous and its local rows are retained for reconciliation. A fully vanished group's explicit action removes its confirmed shortcut and every confirmed-404 row and file, regardless of the individual-row toggle.
The shortcut's Steam-side files — grid artwork, the per-app Steam Input roots, and the localconfig.vdf controller
entry — are removed only when a recovery bundle captured them, because the capture is where their claims are taken and
no claim means no mutation. With recovery off, the shortcut, rows, ROM content, and plugin caches are still removed, but
those Steam files stay behind: the existing user-triggered orphaned-artwork cleanup collects the grid images later,
while the Steam Input files and controller value linger harmlessly under an appId no shortcut uses.
Run outcomes¶
Groups execute serially, and an ordinary group failure does not stop unrelated groups. Recovery, path, disk-space, checksum, liveness, or Steam-acknowledgement failure skips the affected group before any destructive state deletion. Cancellation stops every group that has not started, and abandons the one in flight if it has not committed anything yet. Terminal results distinguish exact success, skipped work, known partial mutation, and ambiguous mutation, and the removed ids and affected appIds stay truthful even after cancellation or a failed event delivery.
cancel_prune(run_id) is the wire entry point, reachable from the confirmation dialog and from the Danger Zone while a
run is live. It is deliberately not gated by the prune claim — stopping the run is the one operation that must stay
available while that claim is held. It cancels only the run whose id matches, is idempotent for repeat requests, and
answers the canonical failure shape for an unknown, finished, or malformed id. Nothing is rolled back: the group already
executing runs to its own verdict and reports what it committed.
Where a cancellation lands¶
Cancellation is cooperative up to the commit point and shielded past it, and the line between them is the first irreversible act: the Steam action, or — for a group with no Steam action — the finalizer's cascade.
Before that line, the backup phase is the only stretch long enough to matter: copying and hashing a selected ROM of
several hundred megabytes off an SD card takes minutes. It is interruptible. The run hands the sealing worker a one-way
stop flag, polled between artifacts and between copy/hash chunks, so a cancellation is noticed within a chunk instead of
after the copy. The worker unwinds through the same failure path as any other sealing error: its staging directory is
removed, no bundle is published, and nothing else in the group has been touched — so the group is reported skipped
with reason cancelled, which is the truth. A cleanup that itself fails is still reported as a preserved unsafe staging
directory rather than rewritten into a tidy stop.
Past the line nothing changes: the group runs to its own terminal verdict and reports what it committed, because a half-finished mutation that nobody recorded is worse than a slow stop. A cancellation arriving during a Steam action or the cascade therefore still waits, by design.
The claim's release is bound to the run task, not to the run body. A task cancelled before the event loop first
schedules it never enters the body whose finally normally releases the claim, so a done-callback releases a claim
stranded that way — otherwise the run id would stay set for the process's lifetime and every conflicting callable would
keep being refused with no run left to release it.
Audit trail¶
A destructive run logs at INFO on the injected logger, independently of the UI that asked for it: run start (run id,
option set, group and candidate counts), one line per group carrying the fresh liveness verdicts the group's every later
decision turns on (which ids RomM confirmed gone, which are still there, which went unconfirmed, which were discovery
candidates, and which row holds the shortcut), a second line per group with its outcome (status, reason slug, committed
action, removed ids, bundle path), and run end (removed ids, affected appIds, cancellation or failure reason). The end
line is written before the completion frame is emitted, so a run whose terminal event never reaches the frontend
still leaves its outcome on disk. Frontend confirmations and cancellations log through frontend_log, so one log holds
both sides of the handshake.