Skip to content

Removed-game cleanup

When RomM stops serving a ROM id, Tender 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 cleanup's entries in the invariant register (Removed-game cleanup) state the rules below and what holds them; this page is where the design behind them lives. Save-side path resolution and backup 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 Use-case 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
services/prune_leases.py Renews, releases and disowns the leases the frontend holds
lib/prune_conflicts.py The prune conflicts — operations, leases, reservations and run claims
lib/conflict_rules.py The conflict rules a use case checks; holds its operation and 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 (GET /api/roms/{id}/simple, whose 404 is the same entity answer as the detail endpoint's) 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 a read of the ROM itself distinguishes a dead id, so every liveness probe is one (get_rom_once).

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, the other is still there" 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 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 Data Management's Gone from RomM row reads two kinds of local evidence. The first is generation-gated: a platform with no known completed fetch produces no candidates from its stamp, because absence from an incomplete fetch is not evidence of removal. The second is the library sync's own mark on a row, below, which reaches the rows no stamp can name. Its one request asks RomM for the ROMs the user hid (#2502 D5; RommApiAdapter.read_hidden_rom_ids): such a ROM is absent from every fetch and still on RomM, so the scan leaves it out of what it takes for gone. The set is kept on the PrunePreview, and the start rebuilds the snapshot over that same set rather than asking again. An answer that cannot be read takes nothing for hidden: the scan then offers such a ROM as gone, and the run's exact-id proof, which RomM answers with the ROM, leaves it as it is. Inline removal from the version picker's trash icon needs neither: the picker offers the icon only after RomM answered that version with a 404, so the preview counts the version it was opened on as gone even where it is still in its platform's last fetch and carries no mark, and the run still takes the same fresh exact-id proof. It offers that one row, unless the two kinds of evidence find every other version of its game gone: then it offers all of them, so the whole game, as the bulk scan would. Either way each preview row says whether it counts as gone (gone_from_romm): by that evidence, or as the version the trash icon was opened on.

Discovery reads the platform's completion stamp whether or not its skip was revoked. The end-of-run stale removal removes the shortcuts of the rows of a platform whose sync is turned off, unbinding each row once the panel reports its shortcut gone, all but three kinds: those an enabled collection returned; those a collection reached that RomM's exact-id answer does not show present — a row with no recorded route counts as reached by a collection when a collection's last completed apply listed it; and those whose game an enabled collection delivered through another version, a skipped collection's members included, which stay bound whatever RomM answers; where such a row is asked about, as one a collection reached is, the answer still sets or clears its mark (#2533 D6). A platform RomM no longer lists keeps its rows bound, but for a collection-reached one RomM still serves whose game the run did not deliver. The stale removal revokes the skip of each platform it removes a row's shortcut on, and "Remove all shortcuts", a per-platform removal and a shortcut deleted in Steam's own UI, which unbind rows too, revoke it for every platform they touch; each keeps the stamp (Backend Architecture, "Incremental skip"). The rows keep their fetch generation and the stamp still records the fetch that set it, so Gone from RomM keeps naming the rows that fetch did not return — including for as long as the platform's sync stays off, while no new complete fetch replaces the stamp. The canary ids a 404 round asks first are read the same way, less the marked rows below.

The library sync marks what no stamp can name. A platform reached only through RomM collections never gets a stamp, since only a platform unit writes one, and a platform whose stamp was cleared, by Force Full Sync or by an apply that did not finish, has none until it is synced whole again: while its sync is on, the next completed apply stamps it and the stamp names the rows its fetch did not return, but while its sync stays off that apply never comes, and only its collection-reached rows are asked about. A platform RomM no longer lists keeps its stamp, which still counts its rows as returned. So the apply's end-of-run stale removal records on a bound row it keeps what it established (Backend Architecture, "The stale removal keeps a game gone from RomM"): roms.gone_from_romm is not_found where RomM answered the exact id of a version reached through a collection with a proven 404, and delisted where the version was kept on a platform RomM no longer lists. A row it removes because the user hid it in RomM has its mark cleared, since RomM still serves it. A preview asks the same questions and writes none of the answers. Discovery adds every marked row to the candidates the stamps give (domain/fetch_generation.py::gone_from_romm_ids), and the Library page's reachable_count leaves out the same rows, and also, from the first apply of its platform after the hide, a version the user hid, which the scan subtracts. The mark is discovery only, never deletion authority: the run proves every exact id again, with its liveness tiers, before anything is removed.

The cleanup's own run marks too. It probes every version of each group it plans, candidates or not, so it can prove a version gone that no stamp and no sync named — an unbound sibling on a platform with no stamp, say, or the version the trash icon was opened on, which has neither a stamp nor a mark behind it. Every version whose 404 stands under the run's proof tiers (still_there, canary_rom, canary_user; an unconfirmed_server 404 never does) while the namespace held is marked not_found, offered for removal or not, unless a request of the same run also answered it with the ROM itself. A version the run removes takes its mark with it, and a row that already carries a mark keeps it. So a run that removes nothing of a game still leaves every version it found gone marked, and the next scan lists all of them at once.

The mark goes when RomM is seen serving the row again: a sync unit commits it (a commit writes the row as RomM delivered it, with no mark), or an apply's exact-id question gets the ROM back. It goes with the row when the cleanup removes it. And it goes when the cleanup's own exact-id check finds the ROM on RomM: the run's prober records every id any of its requests answered with that very ROM, a canary's included, and every 404 that stood, and the run writes both before its completion frame — the clears and the marks of the paragraph above, leaving as it was an id answered both ways — so the list reopened on that frame already shows the result. The prune conflicts already refuse a sync for the whole run, so nothing marks a row again in between. A version the cleanup keeps, such as the one a shortcut is on while another version is still on RomM, stays marked and stays listed. A marked row is never a canary: a platform RomM no longer lists keeps its stamp, so its rows still carry the generation the canary read looks for, and asking one would 404 and leave a fully gone game unprovable.

A run removes only what the dialog showed as removed. The planner removes, as far as the options allow, rows that are both offered and proven gone (domain/prune.py::selected_prune_ids, candidate_ids & vanished_ids), except where every version of a game is proven gone, when the whole-game removal takes them all. Where that whole game holds a version the preview did not offer, it would take that version too, so the group is skipped whole as another_version_gone, Another version of this game is gone from RomM too. Scan again to review it. Together the two keep removal to the offered rows. The other way round, where the preview offered every version of a game, so the dialog showed it as gone entirely, and the check finds one of them on RomM again, the game is removed whole or not at all, so it never loses part of itself: the group is skipped as version_back_on_romm, A version of this game is back on RomM. Scan again to review it., with either whole-game option. A skipped group still leaves its marks, above.

The preview lists every member of an affected group, not only the candidates, so the dialog can say what happens to each game; only the rows it offers — the candidates — are ever removed (above). A member carrying the platform stamp's current fetch generation is not evidence that RomM still serves it, which is why the run probes every id in the group afresh rather than trusting the stored generation. 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.

Each row also carries what the dialog predicts its outcome from: gone_from_romm (above), bound (a Steam shortcut is on that version, beside the group's bound_count) and fs_name_no_ext (its file name without the extension, the stem the sync rebinds onto the same dump by). It also carries is_main_sibling (the group's default version, as the last library sync stored it), which the dialog names a game without a shortcut by. The dialog renders every row with either option on or off and says per row what the run would do; the run's own check still decides, and never removes more than that prediction (above). Every page is still fetched, and the completeness gate before confirmation is unchanged. What the dialog shows is QAM Panel, "The removed-games review".

Cleanup deliberately does not clear the platform's platform_sync_state completion stamp, nor revoke its skip. It does not need to. The incremental skip compares RomM's visible id set with the checksum the stamp recorded (Backend Architecture, "Incremental skip"), and a row the cleanup removes is one RomM answered with a 404. If the stamp's fetch did not return it, the checksum already leaves it out, and once it is unbound the skip's rebuilt unit no longer carries it either. If the fetch did return it, RomM dropped it afterwards, so RomM's id set no longer matches the checksum and the next sync fetches the platform in full. Clearing the stamp would cost the platform its incremental skip and disable further bulk discovery until a new complete fetch landed; revoking its skip would cost the first of those.

Prune conflicts

A running cleanup 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 endpoint holds an operation before anything it does can yield; which detached work holds one for its lifetime is named below. The achievement refresh a game session's finalize starts is detached and holds none, because it only reads — the ROM's RetroAchievements id and RomM's achievement data — and writes nothing but an in-memory cache.

Every prune conflict is recorded on one object, PruneConflicts (its four kinds are defined in GLOSSARY.md → Prune conflicts). The composition root builds it before any service and hands it on. The prune service registers its run there. The conflict rules a use case checks at its entry (GLOSSARY.md → Conflict rules) hold that use case's operation there, hold one until detached work it starts has ended (hold_until_done) — a background save-status check, a download, the outbox flush a play session's start begins, the thread of a removal or of Uninstall all whose call was cancelled — and take and release the leases the use case hands the frontend: a shortcut removal's, a version switch's, an artwork fetch's, a start's preparation and the launch-options reconcile's, and the ones sync_complete, sync_stale and the shortcut artwork job's shortcut_artwork batches carry. The lease service (services/prune_leases.py) renews and releases a lease by its token, and disowns the ones a gone frontend left behind; Endpoints reads none of it. The prune service takes the start's reservation through the rules' hold_start, before it asks the update, migration and sync rules. Before that it stops the shortcut artwork job (stop_for_cleanup), waiting for an icon being written or a batch being handed over, so nothing it writes into the grid directory or hands over lands once the start has reserved; no run of the job starts until a start that begins no run, or the run's end, resumes it (resume_after_cleanup). The job's batch lease is held by the frontend while it sets the batch's icons, wide grids, covers and logos, so a start pressed then is refused, naming "setting shortcut artwork" (Shortcut artwork). The reservation lasts from the moment hold_start takes it until the start returns; the run claim, from the moment the revalidated preview becomes a run until that run ends — in the run's own finally, or, for a run task cancelled before it first ran, in the task's done callback. The run is registered before the reservation is given back, so the two overlap and a conflicting endpoint finds no gap between them.

The start is atomic in the part that matters: hold_start takes the prune conflicts' lock, refuses if any operation or lease is held, and takes the reservation — all in one lock hold, so no claim can slip between the check and the reservation. The start then continues without the lock. The reservation alone already refuses every conflicting endpoint, and holding the lock across the start's preview rebuild would make Play, save status, and downloads wait for that rebuild rather than learning their verdict immediately.

Every operation and lease on the prune conflicts is named. An operation carries its endpoint's own name, detached work carries the name of the endpoint 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 refused start is indistinguishable from a backend that has stopped answering, and the holder cannot be identified after the fact.

The count of live operations and leases is derived from the holder registry rather than tracked beside it: a counter that can drift from the registry leaves a refusal with no holder to name.

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. Every continuation re-checks its abort signal before each later Steam mutation. Failed event delivery releases an unreachable token. A continuation with an owner captures that owner's generation once, when it starts, and carries it through every backend wait, and the owner's teardown tombstones it so a late lease-bearing response is released without doing work; only a genuine remount opens a new generation. The continuations with no owner — the ones index.tsx registers and Steam's launch watcher — have no teardown to tombstone them. An owner's 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 never reaches its release and never renews either, so its lease holds off every cleanup 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, reservations and operations 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. 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.

A game that is running

A running cleanup refuses launch evaluation, but not a game started before it, and a removed game can still be running from its shortcut. A group whose shortcut's game Steam lists as running is skipped whole as game_running, its result naming the game beside "This game is running, so it was left as it is. Quit it and scan again to remove it." — its shortcut, its files and its saves stay as they are, and the next scan offers it again. The group planner asks when it reaches the group (services/prune/planning.py), after the group's admission checks and before any liveness request, and the run asks again before anything irreversible (_armed_to_mutate, services/prune/executor.py), beside its re-check of the downloads; a group skipped there keeps its sealed bundle and reports its path. What counts as running is the save sync's reading (Is the game running), matched by the shortcut's app id, so only the group's bound row can make it run. The preview does not ask: whether the game runs is a question about the moment of the removal, and the preview is taken before the user confirms.

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. A preview page holds at most its row limit, completion results are split into chunks at a byte budget (a single result larger than that travels alone), and a Steam snapshot is capped at a byte count the panel and the backend both measure as compact, ASCII-escaped JSON. No text in these payloads is shortened. A group's result carries at most five warnings and at most 50 ROM ids, each beside its full count. Warnings 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 backup.

The safe root a source is anchored to is matched symlink-resolved, so a root named through a link — /home is a link to /var/home on image-based distributions — still contains the paths recorded under its resolved spelling (#1838). The root only: everything below it is compared and walked exactly as spelled, because resolving those components would resolve away the very symlinks the no-follow walk exists to refuse.

Which folder bounds a game's files, and when RetroDECK cannot say

An installed game's safe root is its system's own ROM folder as the resolver names it, resolved (RetroDeckFolders.rom_folders), and never RetroDECK's whole ROM root: a system folder linked to another drive is where that system's downloads land, and the ROM root would put them outside the bound. The preview measures, the recovery bundle seals and the removal consumes a claim against that same folder (#2285 D1).

What RetroDECK's state does to a run:

  • A root it needs only assumed, or a question about its roots failed — the ROM root or the saves root is one the resolver only assumed or gives no path for (a retrodeck.json without saves_path, a marker-* finding, a RetroDECK not set up), or detecting the sources or asking about its roots raised: the cleanup does not start at all — the start is refused, raising the folder refusal, with the sentence of the finding that explains the root, Tender's own for an assumed root, or RetroDECK's folders could not be established, so Tender downloads into and removes from none of them. Nothing is half done: the saves root is asked at the start too, because the run's save inventory needs it and would otherwise refuse midway (#2244 D3, D5, D13; #2294 D3).
  • Not installed, or naming no ROM folder — the run goes ahead: it removes shortcuts, and the records of games without a download record. A game with one keeps it, its files on disk or not, and is reported with the uninstall's sentence. The record stays until RetroDECK is back; the next cleanup then removes it with its files — "Forget this download" is no way out, since the game has no page once its shortcut is gone (#2244 D9, #2285 D2).
  • The question about one system's folder failed — the start is not refused; within the run that system's games are kept and reported in their results the same way, with the sentence above, and every other system's games are removed (#2244 D13, #2285 D7).

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 backed-up 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.

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 the backup, and the whole set is rechecked collectively immediately before the aggregate cascade — a save an emulator recreated in between stops the deletion.
  • Backup publication is atomic no-replace, so a concurrently created .romm-backup destination 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 Gone from RomM review 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 Back up before removing (default on), and on every row whose downloaded files the run would delete a Back up file switch that includes that installed ROM content (default off), with Back up downloaded game files too setting every one of them at once. 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, and the selected size against every deleted file's and the free space, and blocks confirmation when space is insufficient.

The root is recovery/ inside Tender's folder (adapters/recovery_bundle.py, RECOVERY_FOLDER_NAME), asked at every call, so it follows the place Settings › Tender folder names; bundles an older version sealed under ~/romm-tender-recovery/ are left where they are and not counted. The folder itself is never created by the bundle adapter: before each seal the cleanup has the folder's owner make it ready (TenderFolderService.do_prepare_for_write), which creates a missing folder with its id file, or refuses with folder_unreachable where its drive is gone or it cannot be written — that group then fails with recovery_failed and the reason, and nothing of it is removed. 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, Tender's cache files, 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 the bundle_id the seal is written with, so SEAL.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); every files/NNNNNN blob 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 from the panel to the backend.

Failure is never rewritten into success. Seal, rename and cleanup durability failures surface as exact or ambiguous partial mutations. A ROM's content removal that raised is reported as an ambiguous mutation rather than as nothing changed, because it cannot tell how far it got; its group is retained like that of any failed removal. A save backup that raised is reported the same way: the saves it had already moved are recorded as a change, and the backup as ambiguous even when it names none. 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 funnel, the save backup. 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; that warning is given only for a downloaded version, since a version with no downloaded files has no save files of its own for the cleanup to move.

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 whole game whose bound version has unsynced saves — the local-save check's stranding verdict (Save File Sync Architecture) — is removed only once its recovery bundle sealed. With recovery disabled or failed, the group is skipped instead. Where no bundle was chosen and the check could not read, the skip's message says that Tender couldn't check whether the saves have been synced.

Steam-side outcomes

The cleanup never moves a shortcut onto another version: a shortcut is the game, and the version picker is the way to switch it. A bound vanished version whose game still has a live version is kept with its shortcut and its row, whatever the options. In a game the dialog did not show as gone entirely, a group whose only confirmed-gone row is that one has nothing to do: the group is reported skipped as shortcut_version_kept, The version this game's shortcut is on is kept while another version is on RomM; switch versions first., rather than with the answer that blames the options. Once the user has switched away from it, it is an unbound gone version, removed like any other. A group with more than one bound shortcut is skipped unchanged as multiple_bindings, This game has more than one shortcut in Steam, so it was left as it is. Delete either of them in Steam, run a sync, then scan again.: a run carries one shortcut through a group's removal, so with two it could leave a live shortcut with no row. Neither the cleanup nor a sync removes such a shortcut where the convergence does not reach the game, and a sync's start is where Tender unbinds a shortcut deleted in Steam (Steam Non-Steam Shortcuts).

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, once every one of those rows was offered by the preview.

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 Tender's 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 Gone from RomM pane while a run is live. It deliberately checks no conflict rule, like the run's other endpoints: stopping the run must stay available while that claim is held. It cancels only the run whose id matches, is idempotent for repeat requests, and refuses an unknown or finished id with stale_run and a malformed one with invalid_run_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 run 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 endpoint 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.