Invariant register¶
The invariant register in the repository's CLAUDE.md lists the cross-cutting safety rules: the ones that span files,
so no diff-scoped review sees the whole rule. There, each rule is one binding statement with its enforcement tier and
what enforces it, if anything. This page holds the long form of every entry, in the same order: why the rule exists,
what breaks without it, and where it lives in the code.
The statement in CLAUDE.md is the rule. Where this page and that statement disagree, the statement wins and this page
is the one to correct.
Format: invariant — tier — enforced by.
- Callable failures use
{success, reason, message}(nevererror/error_code) — check —scripts/check_failure_shape.py --check - A definitive 404 is
not_found, neverserver_unreachable— a catch-allexcept Exceptioninservices/may not bind a verdict key (reason/status/recommended_action) to a hardcodedSERVER_UNREACHABLE; route the exception throughclassify_error, or peel the 404 off with a siblingexcept RommNotFoundErrorwhere the verdict is a partial-success flag — check —scripts/check_404_not_unreachable.py --check - A 404 becomes
RommNotFoundErroronly when RomM's entity layer is proven to have answered it; only the three byte-stream fetches opt out — test + prompt-only —TestNotFoundDiscriminationplus the per-call-sitetest_generic_route_404_still_raises_not_foundtrio intests/adapters/romm/test_http.py; a fourth byte-stream fetch's opt-out is prompt-only —.claude/rules/romm-http.md - Every RomM request goes out through
RommHttpAdapter._urlopen, the single point that clears the known-unreachable state — a request method callingurllib.request.urlopenitself leaves every retry ladder degraded to one attempt until an unrelated path happens to succeed, and nothing else fails — check —scripts/check_urlopen_choke_point.py(structural, AST call sites — an alias or agetattrwould slip past it. Which requests may skip the ladder, and which passromm_origin=Falsebecause they do not talk to RomM at all, stays prompt-only in.claude/rules/romm-http.md) - Frontend↔backend callable parity (names + arity) — check —
scripts/check_callable_manifest.py - Every backend
emitevent name has a frontend listener, and vice versa — check —scripts/check_event_parity.py settings.jsonis written only by its owner (adapters/persistence.py) — check —scripts/check_settings_owner.py- Where this program's directories are is resolved once from the environment, and every consumer reads them off
AppDirectories— prompt-only —domain/app_directories.pyis the ladder (TENDER_*, then XDG, then the built-in defaults) and it is pure: the environment is handed in, so every rung is checkable against a table.Plugin.runresolves it once and hands it tobootstrap(), which derives nothing, andRuntimeBundlecarries no directory at all — it used to carry two, and that is how a question about a plugin loader's own layout came to sit beside a question about the user's data as two plainstrfields on structs the composition root passes around. Counting rule (an AST walk for an attribute in{config_dir, data_dir, cache_dir, state_dir, runtime_dir, code_dir, bin_dir}whose base ends indirectories): 18 reads over three modules,main.pyandbootstrap/'s two —code_dir5,cache_dir4,data_dir4,state_dir2, and one each forconfig_dir,runtime_dirandbin_dir. Re-derive it rather than trusting the number. Two fields are read inmain.pyalone and nowhere else:state_dir, which the logging setup opens and which the injection's crash record lives under, andruntime_dir, which the port file lives in.config_dirhas exactly one reader,PersistenceAdapter, andbin_direxactly one, the launcher install inbootstrap/adapters.py. The pairing that matters iscache_diragainstdata_dir— covers, artwork and the SGDB artwork cache on the first because they are re-derivable from the server and the database on the second because it is not; a system that clears caches must be able to clear one and not the other. The launcher's home is the read whose mix-up a user would see rather than the next start only, sincelauncher_in_bin_dir(directories.bin_dir)is carried on asShortcutLauncher.pathand baked into every shortcut'sexe.bin_diris one of the two fields not named after this program (the other iscode_dir, wherever the program was installed; CONTEXT.md's "The program's directories" is the home of that split) — it is the directory every program a user installs for themselves puts a binary in, which is why nothing under it may be treated as ours to remove. Nothing mechanical tells the seven apart: they are sevenstrfields on one frozen struct, so a read of the wrong one is a rename away and fails silently in whichever direction it happened to point. One raw read ofTENDER_CODE_DIRis deliberate and is not a directory read:domain/update_release.py::resolve_update_source, called once byPlugin.runbesideresolve_directories, asks whether the variable was SET and whether it names the directory this process's code sits in — which decides whether this is the installed program an update may replace.AppDirectories.code_dircannot answer that, because the ladder has already folded "set" and "fell back to where the code sits" into one value; the function derives no directory - The identifier's three homes are never derived from one another — in particular
APP_DIR_NAME(domain/user_data_location.py) is never read fromPACKAGE_NAME(domain/identity.py) — test + prompt-only — the three homes and the question each answers are enumerated inbackend/domain/identity.py's module docstring.APP_DIR_NAMEandPACKAGE_NAMEspell the same string today, soAPP_DIR_NAME = PACKAGE_NAMEreproduces every current path exactly and every value comparison stays green — the two are still equal after the fold, which is what makes it invisible; the cost arrives at the next package rename, which then moves every user's library on the following start with nothing failing and nothing said.tests/domain/test_identity.py::TestTheIdentifierStaysInTwoPlacestherefore asks the module what it ASSIGNS rather than what it resolves to: it parsesuser_data_location.pyand fails unlessAPP_DIR_NAMEis a string literal, which is the one answer that cannot be another constant's — a fold through a transform (PACKAGE_NAME.lower()) is a call node and fails too. The reverse fold,PACKAGE_NAME = APP_DIR_NAME, is caught by assertingdomain.identityhas noAPP_DIR_NAMEattribute, and that half is the weaker one: importing it under an alias evades it. The THIRD home is unchecked entirely —SESSION_BREADCRUMB_KEYis frontend TypeScript and no test on either side relates it to the other two. The rule is also stated atAPP_DIR_NAMEitself, because a diff that folds it opens neither the docstring nor this file - Sync run-lifecycle (
sync_state/current_sync_id) written only viaLibrarySyncStateBoxverbs — check —scripts/check_sync_lifecycle_owner.py - A library-sync seam is held only by the module owning the job it belongs to:
active_core/disc_resolverbyservices/library/shortcut_launch_resolver.py,renderer_rss/renderer_gcbyservices/library/session_budget.py, andartworkbyservices/library/cover_preparer.py(the apply path's covers) andservices/library/reporter.py(commit-time cover-path finalisation) — the one confinement with two owners, because those are two different questions a unit asks at two different points; two owners is a named pair, not a licence for a third, and the orchestrator that used to be the third holds noArtworkManagerat all. Theservice.pyfaçade is not a co-owner — it may pass a seam on and may not use one, which the check reads structurally: a seam attribute standing as a call's keyword-argument value, and a seam annotation on a field ofLibraryServiceConfig, are wiring; anything else in the façade is a finding — check —scripts/check_seam_owner.py(AST over attributes named after a seam and over annotations naming its Protocol, resolved per seam rather than per module — owning one grants nothing about another. It sees the injection, not every later use: a seam aliased to a differently-named attribute or local, one reached throughgetattr, one passed positionally into a helper that holds it, a quoted string annotation, and a Protocol imported under an alias all slip past it — the last two only on the annotation half, because the constructor read that unpacks the config is flagged whatever the field is called). What the confinement buys is that each module's transactions stay separable:shortcut_launch_resolverholds a read UoW across its install-path scan while theactive_coreseam opens its ownBEGIN IMMEDIATEper ROM, so folding the two into one pass deadlocks — on a real device only, sinceFakeUnitOfWorkshares no connection. Nothing mechanical stands behind that half.check_uow_seam_nesting.pycatches the fold only in its inline form (the seam method named inside thewithblock); the peer-call form —do_build_core_overridesinvoked from inside the install-path readers' own UoW, which is what putting the three methods on one class makes cheapest — is that gate's documented blind spot and passes green. On the budget side the confinement holdssession_budget's stated promise that no renderer-RSS reading is taken anywhere else in the package - A module declared read-only calls no repository write —
services/library/local_library_reader.pyto start — check —scripts/check_read_only_module.py(AST over the declared file's own calls, matching the two-attribute<...>.<repo>.<method>shape against the twelve repositories the UoW exposes). Read or write is decided by the name's shape —get/get_*/iter_*/countplus the explicitly listed reads — andtests/scripts/test_check_read_only_module.pyre-derives every method name fromservices/protocols/repositories.pyand pins its classification, so a new repository method fails there until it is classified. The two directions are not symmetric. A read named outside the shapes is called a write: loud, safe. A write named like a read is called a read and passes in silence —uow.roms.get_or_create(...)is green today, which is the very accident class this entry is otherwise about, so "repository writes are named as writes" stays a prose rule the gate does not carry. It also sees only calls the file itself makes, and only as calls: a write behind a helper it calls, a write passed as a bound method (run_in_executor(None, uow.roms.save, rom)— an attribute, not a call, and the exact idiom this module's own reads are invoked through), an aliased handle (repo = uow.roms), agetattr-reached repository, and a repository name not in its list all pass green. What it does catch is the plainuow.roms.save(...)dropped into a read module because a UoW was already open there. The declaration earns its keep by making the boundary checkable rather than descriptive: these reads open their own short UoW and are offloaded to an executor at points chosen for cheapness, so a write among them would land at a moment nobody picked. The platform stamp's DELETE stayed insync_orchestrator.pyon those grounds — it is a write, so a read-only module cannot hold it, and it is cohesive with the half of the apply pipeline that performs it: the DELETE stayed with_sync_one_unit, which builds a unit's delta, while the re-stamp went toChunkDispatcher._build_final_platform_stampwith the final chunk whose commit UoW it rides, so the stamp's two ends now sit in two modules and each names the other. Why the DELETE sits exactly where it sits in that pipeline is a property of its call site, not of its module — after the fetch, after the artwork, after the cancel guard, before the first chunk (ADR-0023 / #1025) — and that argument lives in full at the call site's own comment, which is the only place a move could not have carried it away from - An emitted
sync_progressframe stops a run (running: False) only with a terminal stage, and a terminal stage is only ever emitted with the run stopped — test —tests/services/library/test_terminal_frame_contract.py(structural, AST call sites and dict literals across all ofbackend/services— it covers the error paths a behavioural test would have to provoke one at a time, but a frame assembled by a helper it cannot follow, or emitted through an aliased callable, slips past it; its own scope tests pin the producers and the root it reaches, so a narrowing fails rather than shrinking the rule in silence. Frame producers are not confined toservices/library/:services/artwork.pyemits through an injectedemit_progress). The QAM panel derives "a run is in flight" fromrunningand keys the run's end — the status line, the live-ETA teardown, Main's stats re-read and the Sync page's three — on the stage, so a stopping frame with a non-terminal stage would collapse the in-progress rows while ending nothing. The panel cannot defend against it: a barerunning: falseis exactly what the Sync page's own retraction of an optimistic start looks like. Since #1814 the frontend's frame store reads the same discrimination for a rule of its own — a run whose stopping frame carried a terminal stage AND a run id can never be put back in flight, which is what stops the apply loop's next item from resurrecting a run that has already ended — so a stopping frame emitted without a terminal stage would record no ending there either, and the freeze that rule removes comes back - The KIND of run a
sync_progressframe belongs to is stated on it (runKind), never inferred from it — and a frame that states none is rendered as neither of the two answers — test + prompt-only — the backend half is pinned end to end bytests/services/library/test_sync_orchestrator.py::TestRunKindOnTheWire(every frame of a preview run and of an apply run, both terminal frames, and theget_sync_statussnapshot) andtests/services/library/test_state.py::TestRunKind(claimed with the run slot, cleared with it); the frontend half byfrontend/src/utils/syncRunView.test.tsand the slot's three labels infrontend/src/bigpicture/MainPage.test.tsx. Nothing joins the eleven sites it passes through, counted one per site at the granularity this list names them:LibrarySyncStateBoxholds it with the slot, three separate backend frame builders carry it (emit_progress,_finish_sync's CANCELLED terminal, and the per-unit ERROR dict literal insync_orchestrator.py), three frontend start paths stamp it themselves on the optimistic frame they show before the first real one arrives (useSyncPage'scomputePreviewaspreview, itsapplyPreviewandstartRunDirectlyasapply),SyncProgress.runKindanduseSyncRunViewpass it through, andMainPageboth seeds it from theget_sync_statussnapshot onto the store at mount and maps it to the slot's label. Nothing mechanical stands behind the seam between them: a fourth frame builder that omits the key, a fourth start path that stamps the kind it is not, or a reader that spends the absent case on one of the two answers — arunKind ?? "preview", a=== "preview"where the neutral branch was — goes green, because each test above pins one half and none of them pins the join. The failure is silent and worst exactly where the frontend cannot help itself: after a plugin reload mid-run the store starts empty, the snapshot is the only thing that can say what the run is doing, and Main then tells the reader a real apply run is merely checking for changes. Why the kind cannot be derived at all is stated atdomain/sync_run_kind.pyand indocs/architecture/qam-panel.md's Main section; do not restate it here - A press that starts a run clears the previous run's per-unit rows — unless that press is a RESUME, the one start
they are still true for — test + prompt-only —
frontend/src/bigpicture/SyncPage.test.tsx's "a previous run's rows at the next press" pins all three start paths in both directions, andfrontend/src/utils/runUnitsStore.test.tspins the clear itself. The rule spans three modules and nothing joins them.utils/runUnitsStore.tsholds the rows and offersclearRunUnits;useSyncPagedecides, at each of the three presses that write an optimistic frame (computePreview,applyPreview,startRunDirectly— the same three the entry above names); andindex.tsx'ssync_planlistener is the only OTHER thing that ever replaces the rows, which is what makes the press the moment that matters. The plan arrives afterbuild_work_queue()on the two apply paths and never at all on the preview path, so a fourth start path that forgets the clear leaves the previous run'sdonerows — with its apply results, and with the unit it died in dressed as running by this run's frames — standing over the new run for the length of a work-queue build, or for the whole of it. The store's own guards cannot help: they REFUSE a foreign frame, and refusing is not clearing. The discriminator can be nothing but the frontend'ssyncResumeState(stats).canResumeat the press, because a resume is a new run with a new id and the backend has no resume concept at all — no frame, kind or id tells the two apart. Both directions fail in silence: forget the clear and another run's rows read as this run's progress, clear on a resume and the one start whose rows are true loses them - A firmware answer nothing could establish is
unknown, nevernot_needed— and the distinction survives every layer it crosses — test + prompt-only —tests/adapters/test_atlas_firmware.pypins the adapter's degradation (a raising resolver, a missing installation, an answer with no root all come back withresolvedclear, never as an empty catalogue reading "nothing needed"),tests/domain/test_firmware_wants.pypins the classification, andtests/services/test_firmware.py::TestCheckPlatformBiosUnknownpins the same listing answeringnot_neededunder a whole reading andunknownunder a partial one. The rule spans four modules and no diff-scoped review sees it whole: the adapter decides whether the reading happened,domain/firmware_wants.pyholds the two values apart,services/firmware/status.pyscopes the doubt to the emulator the platform launches with, and both frontend surfaces render them as different sentences. Nothing mechanical stands behind the scoping half. A future caller that folds the two values back together — a truthiness test on a placement, awanted != "needed"bucket, a default ofnot_neededwhere the catalogue is silent — goes green: the collapse is the upstream defect this swap removed, and it is one carelessoraway from returning. The scope is the second half, and since #1821 it is ONE emulator rather than a platform's whole list:reading_complete_fortakes the launching emulator's identity and refusesNone— an unresolved pick, or one the resolver could not identify. Read as complete, aNoneis a finished reading of nobody: every server file classifiesnot_needed,required_countis 0, and the platform reports a green "Nothing required" over firmware the emulator will not boot without. An unread emulator the platform also offers no longer withholds the answer, which is deliberate — it says nothing about a launch that does not use it — and the cost is that switching a platform's emulator can move it from a finished answer to a withheld one.declaration="packaged"with an EMPTY requirement list counts as unread and is the shape most likely to be folded back the wrong way: a card may identify its image by content, so it names no file until the bytes are read, and reading the empty list as "wants nothing" puts a green all-clear on a PlayStation launching DuckStation - A firmware row the RomM library does not hold (
on_server: False) counts towards readiness, and never towards a download affordance or a progress ratio — test + prompt-only —tests/services/test_firmware.pypins the row's shape (idabsent,on_serverclear), that it raisesrequired_count, and that it stays out ofserver_count;frontend/src/bigpicture/library/PlatformsTab.test.tsxpins that the buttons key off the fetchable set. The three axes live in three places and nothing joins them.domain/bios_status.py::count_requiredis readiness and counts every required row;services/firmware/status.py::_bios_aggregatesscopesserver_count/local_counttoon_serverrows; the download buttons' condition isisFetchable(frontend/src/utils/biosFetchable.ts), called fromfrontend/src/bigpicture/library/PlatformDetail.tsx— and since #1815 the per-row Download button reads the same filtered set, so a fourth reader of the axis now exists in that one file. A fifth reads it in the same file for the On-disk cell's second mark (⊘), and that one is display alone: it neither counts nor gates, which is what keeps it out of all three folds below. A sixth reader is the game page's BIOS tab (BiosTab.tsx'srowBelongsOnThisPage), and it is display alone in the same sense: it decides whether a row gets a LINE — a file no page can fetch, that this launch does not require and that is not there, is nothing that page can act on — and gates no count and offers no action, so it belongs to none of the three folds below either. What holds the two surfaces together is that they call one predicate rather than spelling the three clauses twice: a second copy would let the game page point at a download button the platform page does not offer, or leave off a row it does. What must NOT be shared is the game page's rule around it — required for this launch, the console's own image, present, fetchable, or unjudged — which is that page's alone; the platform detail has no such rule, and a shared "visibility" module would invent a notion only one surface has. Each fold has its own quiet failure: drop the row from readiness and a platform reads ready while a required file is absent; add it to the ratio and a SNES page reports0 / 26 files, 26 missingfor twenty-six optional files no core wants; add it to the buttons and the page offers a download that cannot succeed.on_serveris the one field all three read; the row'sid: Noneis an honest absence with no consumer at all, so nothing breaks if it is filled in and nothing is guarded by leaving it empty - No BIOS answer outlives the page that asked for it — test + prompt-only —
tests/services/test_game_detail.py::TestGetCachedGameDetailCarriesNoBiosAnswerand the two contract cases intests/contract/test_game_detail_read.py.get_cached_game_detailcarries none and says so (bios_status_unknown, plus an unconditionalbiosstale field); the liveget_bios_statusfills it in. What holds the rule is an absence —BiosCheckerhas one method, so there is no cheap cached twin to reach for — and an absence is exactly what a future change restores without noticing. Re-adding a stored answer would look like a performance win and would put a previous page open's requirement on this page - The plugin attaches a configured custom header to a RomM-origin request and to no other request it issues, and never
over a header the adapter sets itself — test + prompt-only —
tests/domain/test_custom_headers.pypins the validation in both directions (every reserved name case-insensitively, a CRLF in a value, and what the persisted reading skips), andtests/adapters/romm/test_http.py::TestCustomProxyHeaderspins the three attachment points, the one exclusion, and that a hand-plantedAuthorization/Hoststill loses. Nothing joins them. The rule spansdomain/custom_headers.py, the transport's_apply_origin_headersand its three callers —_apply_default_headers(every authenticated route),unauthenticated_post_json(the pairing-code exchange) andbasic_auth_request(the token mint) — plus the one place that must NOT call it,download_external. Both directions fail in silence and each one is worse than it looks. A fourth request method that forgets the helper works perfectly for the user who has no proxy and 403s for the user who has one, on that path only. Adding it todownload_externalhands the user's proxy credential to a third-party metadata CDN, which no test would notice because the fetch still succeeds. What the plugin issues is the whole of the claim:_urlopenuses the default opener, so urllib's redirect handler follows a 30x by copying every header butcontent-length/content-typeonto the next request with no same-origin test — measured, not read: a cross-host 302 delivers both the configured header and the RomM bearer to the foreign host. That is the transport's behaviour and predates this rule (the bearer always travelled it), which is why the invariant is worded about attachment rather than about arrival; #1889 holds the gap. The reserved set is held by two mechanisms, not three, and they are not equally strong. One is validation —_name_refusalagainstRESERVED_NAMES, reached from bothresolve_custom_headers(the wire) andstored_custom_headers(every request, so a hand-editedsettings.jsoncannot route around it). Those are two call sites of ONE frozenset: drop a name from it and both gates open in a single edit. The other is attachment ORDER, and it covers only a name the adapter itself re-adds after_apply_origin_headers—User-Agent,Authorization,Content-Type, and conditionallyAccept-Encoding/Range/If-None-Match/If-Modified-Since. It coversHostandContent-Lengthnot at all, because the adapter sets neither:http.client._send_requestsuppresses its own derivedHostwhen the caller supplied one, so a configuredHostretargets every request's virtual host — and for that name, the one this list singles out as dangerous, the frozenset is the only defence there is. Nothing pins the ordering leg either: the two tests that look like they do (..._never_displaces_the_bearer,..._never_retargets_the_request) pass becausestored_custom_headersdrops the entry long beforeadd_headeris reached, so both stay green if the ordering is reversed. Detail:docs/architecture/backend-architecture.md→ "the headers every RomM-origin request carries" dist/globals.jsis never evaluated into Steam where Decky Loader is serving, and what decides that is read from the MACHINE rather than from the window — test + prompt-only —tests/host/inject/test_bundles.pypins both choices in both directions (the globals bundle absent beside a serving loader, and the standalone panel absent too, since it carries the same sweep), andtests/host/inject/test_injector.py::TestBesideDeckyLoaderpins it end to end over a real socket with something answering on the loader's port. It is the only crash cause ever observed here: that bundle carries@decky/ui's module sweep at import scope, and re-running it under an interface already rendering from those modules takes the Big Picture window down. The rule spans three modules and nothing joins them.host/inject/machine.pyasks whether Decky Loader's server answers,host/inject/bundles.pyturns that into a file list, andhost/inject/injector.pyevaluates it. A fourth caller building its own file list, or one reading the window instead, goes green: at the moment the question has to be answered every Decky marker on the window —DFL,DeckyPluginLoader,DeckyBackend,deckyAuthToken,deckyHasLoaded— is stillundefined, so a window probe answers "no Decky" on a machine that has one, picks the globals, and crashes the interface. The gap between "the loader's server answers" and "Decky is rendering" is deliberately left on the safe side, and the ordering measurement (Tender loading first is safe) may not be leant on to close it the other way: a reconnect puts the same question at a moment when Steam has been up for an hour. Detail:docs/architecture/loading-the-panel.md- Where the globals bundle is loaded, its installer is CALLED between the import that defines it and the panel import,
and the panel is not imported unless the report says every global it names is installed — test + prompt-only —
tests/host/inject/test_bootstrap.py::TestItRunsUnderNoderuns the bootstrap under node against a stub page, with the bundles asdata:modules that record having run and that leave the installer on the window where the real one does, and pins the order, the refusal and each refusal's sentence;test_bundles.pypins that the globals bundle comes first;TestTheInstallersNameholds the property's spelling in Python tofrontend/src/boot/steamGlobals.ts. The rule spans three modules in two languages:host/inject/bundles.pysays WHICH file installs (globals_at),host/inject/bootstrap.pyturns that into import, call, import and into the gate, andsteamGlobals.tsleaves the installer under the shared name and decides the report's keys. What nothing checks is the seam the node tier stubs: the stub report is written from the TypeScript interface rather than derived from it, so aGlobalsReportthat renamed theinstalledfield passes every test named here and fails on a device the same silent way — the bootstrap reads no keys, refuses, and leaves no panel, a card, and a green suite. Renaming the keys INSIDEinstalledcosts nothing, because the gate is generic over whatever the report names; renamingsteamReadycosts only the readiness sentence, and nothing on either side holds one spelling to the other - An injection that could not be observed is never counted as a crash, and this process answers for its own record
before it reads one — test + prompt-only —
tests/host/inject/test_watchdog.pypins the state machine in every direction and which of the three answers settles an armed record;tests/host/inject/test_injector.py::TestDidTheInterfaceSurviveItpins every way a record is answered for over the real loop — the survival, the collapse that leaves it open, and each way an attempt is closed without being counted. Count those by their call: a test in that class that ends with the recordopen: falseandfailures: 0is one of them. One more way lives in its own class,::TestWhatThisBackendDoesToSteamIsNeverACrash, because it is reached only through replacing a panel an earlier backend left behind. That test drives the reload; the fallback's SIGTERM tosteamwebhelperbumps the same counter and has no test of its own, because every path to it passes the reload's bump first. The crash cannot be counted inside a session — it leavesSharedJSContextalive with our marker on it, so the injector sees "already injected" and never tries again — so what is counted is a record left open at the NEXT attempt, and two in a row stop the injection. Three things have to hold together and nothing checks that they do.judgewrites its resolution back before it answers, or one open record counts once per attempt for ever. The ORDER is the second: an injection answers for the record this process already holds beforejudgereads one, because a JS-context rebuild inside the alive window starts the next injection while the last record is still open and unanswered — ordinary during start-up settle, and what a Steam restart produces — and read the other way round it is a crash that never happened, twice over on a machine where nothing was wrong. A real crash is unaffected: the check that saw it marks its reading taken (stays_open) and the record stays open for the nextjudgeto find. The third is that the alive check closes the record without counting whenever nothing was established — the debugger stopped answering, the backend is shutting down, nothing but the renderer was open when the panel was loaded, a second injection began before the check that answers for the first could run, or this process took the interface down itself after the panel was loaded (the reload that replaces a stranded panel, or its fallback's SIGTERM tosteamwebhelper, each counted inPanelInjectorbefore it happens and compared by the check against the count at arming) — because the signature is specific (every other page target goes at once while the debugger keeps answering), and a run in which that could not be observed says nothing. A close that counted any of those would stop the panel loading over a user closing Steam, and the failure is silent in both directions: too lenient and a crash loop is never stopped, too strict and the panel disappears with only a log line to say why. The way back is not inside Steam (the interface is what is gone): the fingerprint — Tender's version, the bundle bytes, Steam's client build — drops the count on its own, andTENDER_INJECT=forceis the switch the refusal line names - This machine takes Steam's interface down to replace a stranded panel at most twice in ten minutes, across backend
starts; a reload Steam does not refuse and the fallback's SIGTERM both count — test + prompt-only —
tests/host/inject/test_reload_limit.pypins the record (under the limit, at it, the window passing, a clock set back, a record it cannot read or write, an entry no float can hold), andtests/host/inject/test_injector.py::TestAcrossBackendStartspins it over the real loop from a record written before the backend starts: a reload under the limit, nothing at it and nothing recorded for the refusal, the fallback refused because the reload before it counted, a reload Steam refused left uncounted and one that got no answer counted, and takedowns the window has passed forgotten. Why the record outlives the process, why two, and why one it cannot read or write blocks nothing: a panel an earlier backend left behind. The join is prompt-only:recovery.pygoes through_may_take_the_interface_downin front of both takedowns it performs and callsReloadLimit.recordonce each is under way — the SIGTERM before it is sent, the reload once Steam has not refused it — and nothing checks that a third takedown does the same; it would pass every test above and reload in a loop again - Tender's Quick Access entry composes with Decky's rather than going through it, and everything it binds to the Quick
Access window is bound from inside that window's React tree — test + prompt-only —
frontend/src/qam/quickAccessEntry.test.tspins what a render pass does to a tab array (added once, added again to the replacement array a remount builds, moved to the end whenever a pass finds it higher up, and recognised by its marker alone in an array this module has never seen), each case mutation-checked. Everything above that line is device-only: whether the two renderers are found, patched and hand back a tree the resolver can walk was measured in #1897 and nothing here re-measures it — a suite that faked it would assert against a tree it wrote itself. The rule spans the frontend entry and the backend injector, and nothing joins them. Three halves fail green. (1) The entry carriestenderas its marker and its key and neverdecky, and nothing writeswindow.__TABS_HOOK_INSTANCEor calls__TABS_HOOK_INSTANCE.add(): Decky's own render counts itsdecky-marked entries against its list length, so a foreign entry there desynchronises that guard into re-pushing every tab with no convergence, and its constructor callsdeinit()on whatever it finds in that global. Both are read off Decky's source rather than measured — theadd()route was deliberately never taken, so its runaway was never observed, and observing thedeinit()would mean breaking Decky's boot on purpose. Neither is a presence check and neither may become one; which bundle is loaded was already decided from the machine (backend/host/inject/machine.py). (2) No tab array is held anywhere, which is available only because there is no unpatch: the injector refuses to load the panel into a context already carryingwindow.__tender_panel__(backend/host/inject/bootstrap.py), and what clears that marker is a JS-context rebuild, which takes the module, its patches and every array with it. Re-adding an unpatch is therefore also re-adding a reason to retain arrays, and the spike's shape — aSetof every array ever pushed into — leaks one dead array per Quick Access remount, with the strip's entries and their React elements, for the life of the process. (3) The placement is re-asserted on EVERY pass rather than set at creation, becauseafterPatchruns the previous handler first: whoever patches last lands lowest, measured both ways, and install order is a property of which program starts first. A handler that pushed once and trusted the order goes green here and comes out above Decky on exactly the machines where TENDER started first — it pushes first and Decky pushes under it. Where Decky started first the single push already lands lowest, which is the case re-assertion does not have to fix and the one a developer is most likely to test. The third rule's own half — nothing binds to the Quick Access window at module scope — is unmechanized and unpinned: that window is replaced by every remount, so a listener, observer or stylesheet held across one is bound to a document nothing renders. What holds today was measured rather than assumed — an unfiltered grep overfrontend/src(tests aside) foraddEventListener(,ResizeObserver,MutationObserver,ownerDocument,defaultViewandcreateElement(, with the enclosing function of every hit read. Neither observer term is prefixed withnew, and that is what makes it find anything: this repo's realm rule takes the constructor off the node's own view, so every observer here is spellednew view.ResizeObserverornew panelView.MutationObserver, and a pattern anchored onnew ResizeObservermatches nothing infrontend/srcat all. The sweep's own boundary is worth stating, because a reader re-deriving it meets the other kind first: aglobalThislistener binds SharedJSContext's window, which the menu's remount does not touch, so those are out of scope however many of them there are. Every binding into the MENU's window sits inside an effect or an event handler of a component the menu mounts:utils/qamExpansion.ts's stylesheet andMutationObserver,utils/entryFocus.ts's focus listeners,bigpicture/layout/WidePage.tsx'sResizeObserver, andbigpicture/layout/ScrollRegion.tsx, which reads the view per event and retains nothing. The glyph binds nothing at all — it is static and reads no state. (utils/styleInjector.tswrites intofindSP()'s document, which is the game page's and not the menu's.) One added at module scope would work perfectly until the first Gaming-Mode-to-Desktop switch and then do nothing, silently. Detail:docs/architecture/qam-panel.md→ The entry - Tender's section reaches Steam's game page through the ROUTE component's
renderFunc, and never through the page component's owntype— test + prompt-only —frontend/src/bigpicture/patches/gamePageSeam.test.tspins the half that is decidable without Steam: the factory predicate in both directions, that two matching factories answer as no match, the shape predicate that decides whose sources are read at all, the memo selection (including an export whose getter throws), and the per-render decision — wrapped once per PROPS object, never per component and never perrenderFunc. Each case mutation-checked. Everything the install does with those answers is device-only: obtaining Steam's webpackrequire, reading the factory sources, patching the memo and adopting a mounted page areinstallGamePagePatch.ts, which is coverage-exempt because a suite that faked any of it would assert against a registry and a fiber tree it wrote itself. The rule spans that file, the seam module, the patch it installs (gameDetailPatch.tsx) and the start-up check, and nothing joins them. Why the page component is the wrong seam is the half a reader will re-derive wrongly:@decky/ui's tree patcher caches the wrapped component per ORIGINAL type (dist/utils/react/treepatcher.js,handleStep), so once any plugin has wrapped the page component every later render goes through that cached copy and a patch installed on the original is never entered again. That is the ordinary case rather than a corner: this backend starts after Steam has been running, so Decky's plugins have already wrapped the page. The failure is silent and machine-dependent — green suite, green gate, and the section simply never appears on a machine that runs Decky Loader while appearing on one that does not, which is the difference this program exists not to depend on. Two further halves nothing checks: the install patches EVERY memo export of that module whosetypeis a function rather than picking the route out (nothing on an export says which one it is, and on any other export the handler finds norenderFuncto wrap), and the start-up check'sAppDetailsRouteentry costs afeaturebesideappDetailsClasses, which costs one for the same reason — every read of it is in that same patch. Moving either topaneltakes the whole interface off the air for a section outside it; moving a panel name tofeaturebeside them renders a hole. Detail:docs/architecture/frontend-bundles.md→ Tender's section on Steam's game page - Aggregate state mutated only via verb-named methods (no field assignment) — check —
scripts/check_aggregate_field_assignment.py - No UoW-opening seam (ActiveCoreResolver, RelaunchOptionsResolver, uow_factory) is called while a UoW is open on the
same path — check —
scripts/check_uow_seam_nesting.py(the first of the two rules that script carries, over one shared matcher; the file-I/O rule below is a different hazard with its own seam list and its own failure message, and neither entry is evidence about the other) - No file-I/O seam is called while a UoW is open — a Unit of Work wraps database reads and writes, never file or
server I/O (CONTEXT.md → Unit of Work, ADR-0006) — check —
scripts/check_uow_seam_nesting.py, second seam family (IO_SEAM_METHODS). The list is the seams this checker can see and has been told about, never an inventory of the I/O seams that exist:DiscResolver.enumerate_discs/.resolve_for_install(a recursive walk of the ROM's install directory), the threeCoreInfoProviderreads —get_active_core,get_default_emulator,get_emulator_options— which are answered by the vendored resolver's live read of ES-DE's catalogue (a system's first read opens it and the adapter's per-system cache is what a second one hits;get_emulator_optionsadditionally globs each bakeable standalone option's emulator install through the find rules on every call, uncached so a component installed mid-session is seen),SandboxLauncherFn(re-probes the flatpak roots fores_find_rules.xmland re-stats it before it may use the parse cache),SystemResolver(parses the plugin's own bundledconfig.json, not RetroDECK'sretrodeck.json, and does no network work despite living on the RomM HTTP adapter),SystemSupportedExtensionsFn/SystemKnownFn(two more questions to the same catalogue, through the same adapter cache),SteamConfigStore.read_shortcut_exes(parses Steam's wholeshortcuts.vdf— 315 KB and 828 entries on the reference machine — for the one-time shortcut relocation. Listing it changes nothing at its only call site: the service reaches it throughrun_in_executoras a bound method, which is this checker's documented blind spot, so the entry is a statement of the rule rather than an enforcement of it. It is also not the store's only real I/O —grid_dir()is called fromservices/artwork.py(six sites),services/shortcut_removal.pyandservices/library/reporter.py, andcheck_retroarch_input_driver()fromservices/settings.py— those are unlisted, and their being unlisted is a gap, not a judgement),FirmwarePlatformResolver(reads what one system's emulators want WITH content verification: it opens each candidate in a declared folder and reads it the way the emulator does — 64-318 ms per system on the reference machine) and its whole-machine siblingFirmwareResolver, the save answer —resolve_save_answerand the saves package's ownsave_answerwrapper, 170 ms warm and 490 ms cold per ROM, which makes it the most expensive entry in the list — the savestate question put to the same catalogue entry (resolve_savestate_location) and the seam's detection question (installation_detected), the two path resolvers —MigrationFileStore.realpath(one walk per stored RetroDECK-home marker, a directory that may sit on the SD card the marker is pending a migration away from) andResolvedPathFn(the same walk, but on both sides of a comparison, so a call site costs what the rows it checks cost, not what it checks them against) — and theRetroDeckPathsgetters that answer with a root:bios_path,roms_path,saves_pathandretrodeck_home, four of the Protocol's five path getters, each resolving on every call. The fifth,config_path, stays out because it resolves nothing — it isos.path.joinover the user home, so calling it costs no I/O. Those two timings are the only entries a cost was measured for; every other one is listed from reading its implementation. One other real I/O seam was weighed and kept out — the reason is in the script's docstring, and it is not an exemption; nor is it an inventory of what else touches the disk. "It's only a read" is the reasoning this rule exists to refuse:SqliteUnitOfWork.__enter__issuesBEGIN IMMEDIATE, so even a read-only UoW takes the write lock. The database is in WAL, so readers are unaffected — but every other writer waits on the lock for up tobusy_timeout=5000and fails withSQLITE_BUSYif it is still held then, andFakeUnitOfWorkshares no connection, so no unit test notices. Six call sites had drifted across the rule before anything looked (#1779), for the reason the check exists: nothing at a call site reveals that an injected seam touches the disk. The rule and the gate come from reading code — no measurement of how long any of those transactions actually held the lock exists, and nothing here should be read as one. What the check sees is the deadlock rule's matcher unchanged — an attribute call naming a listed seam, lexically inside awith <...>uow_factory()block in the same function scope — so it inherits every blind spot of that half: a seam behind a helper one level down, an alias to a local, a factory attribute whose name does not end inuow_factory, a nesteddef/lambda(which resets the scope by design), a seam passed as a bound method (run_in_executor(None, self._disc_resolver.enumerate_discs, install)— an attribute, not a call, andrun_in_executoris exactly howdisc.pyandcores.pyreach their_iobodies; the same shapecheck_read_only_module.pyrecords for its own gate), and the hand-maintained list itself, which cannot notice a seam whose implementation grows a file read later. Matching only attribute calls is deliberate: the puredomain.disc_selection.enumerate_discsshares a name with the seam and does no I/O — it is safe because its call site imports it bare, not because of the name. The call-shaped blind spot is shared with the deadlock rule and only this family closes it: for each__call__-only seam the list carries the attribute it is bound to — by convention rather than by construction, and only while such a name means one thing, which is exactly what keeps_list_filesout. Which entries are call-shaped, and which of those also carry a twin under their implementation's own method name, is in the script's module docstring. The deadlock rule's own call-shaped seams stay open.SystemResolveris the odd one out for a second reason: the adapter memoises its map for the life of the process, so exactly one call ever opens the file, and the entry earns its place because that one call can land inside a UoW. One# pragma: no uow-checkcovers both families — it suppresses the line, and no seam is in both lists, so where a line does name two seams it silences both - A shell function whose value is taken with
$(...)never reachesexit— it answers, and its caller aborts — check —scripts/check_shell_answer_functions.pyoverinstall.sh,scripts/package.sh,bin/tender-rom-launcherand every*.shunderscripts/andbin/(the launcher is named because it carries no extension for the glob to find).exitinside a command substitution ends that subshell and nothing else, so such a function prints its message and the CALLER runs on with an empty answer — a second complaint about the emptiness, or a request built out of it, and non-zero either way, which is why the shape survives a test that reads only the status. Which helper ends the run is DERIVED (a function reachesexitif it runs one or calls a same-file function that does), so a second abort helper is covered the day it is written; the check follows the chain and names it. Its reading is a hand-written lexer — quotes, comments, heredocs, arithmetic, expansions,$( )and backtick nesting — rather than a bash parser, so a construct it misreads drops real code in silence: the failure is a function never collected or a body that ends early, and theexitbelow it is then simply not there. Eleven such shapes are read for by name — a closing}judged by what FOLLOWS it (an unquoted${x}or afind … -exec rm {} \;ended the enclosing function), a}written as an ARGUMENT (echo }) and a brace group opened after!(if ! { exec 3< /dev/tty; }, where the{was not a block's while its}was),$(( 1 << 3 ))read as a heredoc (which blanked the rest of the file) and a$(( … ))span ending one parenthesis short, a parameter expansion naming a function read as a call to it, acasearm's)ending the substitution it sits in, the POSIX arm written(a)whose leading parenthesis groups nothing, the fallthrough terminators;∧;&, a( … )subshell inside a substitution whose closing parenthesis would otherwise end it, and a backtick substitution inside double quotes read as string text. The enumeration is the script's docstring; this is its summary, and the two are re-derived together. The blind spots left are the list in the script's own docstring, which is their one home; it names, among others, a function reached through a variable (install.sh's ownstepis the live example),( f )andf | cmd, and a function defined twice at the top level. A call written inside$( )is deliberately NOT an edge in the graph — anexitthere ends the subshell — so a nested pair is reported once, at the inner site - Services never call clocks / sleep / uuid / random directly (inject the Protocol) — check —
scripts/check_cosmic_call_bans.sh - No module in
services/,bootstrap/,adapters/,domain/,lib/ormodels/crosses the ~1000-LOC decomposition threshold, and the ones already over it may not grow — check —scripts/check_module_size.py(the modules that predate the gate are grandfathered at their exact size. A ceiling goes up only for a change that adds no code — a rename, a reformat — and only with the reason recorded at itsALLOWLISTentry; a raise taken silently has retired the gate. Entries only ever come out, when the module drops back under the threshold.main.py,_vendor/,tests/,scripts/andfrontend/src/are out of scope, each for a reason recorded atSCOPE_DIRS) - Service-independence contract list stays complete — check —
scripts/check_service_independence_contract.py - Layer import direction (services ↛ adapters, adapters ↛ services, …) — check —
.importlinter(lint-imports) - Frontend direction:
frontend/src/utils/andfrontend/src/api/never import either surface (frontend/src/bigpicture/,frontend/src/desktop/); the two surfaces never import each other; and nofrontend/src/module takes part in an import cycle — check —frontend/eslint.config.js(import-x/no-restricted-paths,import-x/no-cycle). The surface pair is a peer rule, not a layer rule: the two share data and logic and almost nothing visual, so anything that turns out to belong to both moves DOWN intoapi/,utils/ortypes/, never sideways. These rules go inert rather than loud when misconfigured: until the config names.ts/.tsxfor the plugin to read,no-cyclefinds no cycle among the frontend's modules (the comment atimport-x/extensionsinfrontend/eslint.config.jssays how).frontend/src/eslintBoundaries.test.tslints known-bad fixtures through the real config and fails if any of the seven stops reporting — a greenpnpm lintalone proves nothing. Type-only imports are not edges (erased at runtime), which is why theapi/backend.ts⇄utils/cachedGameDetailStore.tsback-reference is not a cycle - No bare
# type: ignore/ blanket suppressions — check —scripts/check_no_bare_ignores.sh - A transport failure and a callable's own failure never arrive in the same shape, on either end — test +
prompt-only —
backend/host/protocol.pystates the vocabulary and.claude/rules/host.mdholds the backend half; the frontend half isfrontend/src/api/hostSocket.ts, which THROWSHostTransportErrorfor anerrormessage and resolves only areply, so a transport reason cannot reach a reader of{success, reason, message}.hostSocket.test.tspins both directions. Nothing joins the two ends:connection_lostis the one reason no backend ever sends — the caller's own register answers with it — and it is spelled once in Python and once in TypeScript with no check that the two agree. A frontend that spelled it differently would go green, and the divergence would surface only to whoever eventually matched on it - The standalone panel bundle carries
@decky/uiand the coexistence one carries none of it — check —frontend/scripts/check-bundle-shape.mjs, over the built artifact rather than a bundler setting (nine strings that exist only in the package's implementation, plus theDFL.read count, in both directions; the licence file the standalone build owes and each bundle's own build stamp are asserted there too). Both failures are silent in CI and land on a device: a standalone bundle that lost the package throws on its firstDFL.read where noDFLexists, and a coexistence bundle that gained it re-executes the modules a rendering Decky is rendering FROM, and takes the Big Picture window down. The check sees the artefacts and not the decision: which of the two the injector loads isbackend/host/inject/bundles.py's, and nothing here would notice the wrong one being served - Tender's three React globals are spelled exactly the way Decky Loader spells them — test —
frontend/src/boot/steamGlobals.test.ts, which readssteamGlobals.tsand the pinneddecky-globals-block.txtas TEXT and compares the four search predicates, which global each answer is assigned to, and the JSX stand-in's keys and aliasing. The cost of a difference lands on DECKY's users, not ours: its loader skips its entire globals block whenSP_REACTis already set, so when ours runs first, Decky's whole frontend renders through our shape. The pinned copy is the half nothing can check — it is upstream's file, held still by hand, so a refresh that is wrong reads as agreement; the provenance header names the commit it was taken at so the question can be re-asked rather than trusted - Every value the panel imports from
@decky/uiis classified by the start-up check — test —frontend/src/boot/steamModules.test.ts, which sweeps every non-test module underfrontend/src/and fails on a name that is in none of the four lists (a search it asks, a name it cannot answer for, a name answered by Steam's runtime state, the package's own code). The swept set is derived rather than listed, because a file missing from such a list carries no lock at all. What it cannot see is whether a classification is TRUE: two names sit in the unverifiable list because they are wrappers the package always defines, and moving a real search there to quieten the check would pass green and leave the panel rendering a hole where the check reported everything resolved. The one list it CAN judge isASKED_LIVE, which the test derives rather than checks for membership — it walks@decky/ui's shippeddist/for exported functions reachinggetGamepadNavigationTrees,getFocusNavControllerordocument.title(through a module-private helper within a file, which is what carriesuseQuickAccessVisiblethroughgetQuickAccessWindow) and holds that set, intersected with what the panel imports, EQUAL to the list's keys, with none of it inSTEAM_LOOKUPS. That axis is the rule the whole check rests on: every entry reads the module registry or a bootstrap global — one registry, the same in both of Steam's modes — and not what Steam has mounted or focused, which the same question answers differently a second later. A start-up reading offindSPrefused to mount the panel in the desktop client over a Big Picture tree that had not been built, and blamed Decky Loader for it. What the derivation cannot see is a runtime-state reader reached through a name the sweep does not know — an arrow export, a re-export, or another module's helper (showModalcallsfindSP() || windowfromdist/components/Modal.js) — and it seesfunctiondeclarations only, not nested in another. What the sweep cannot see it says nothing about: such a name can sit inSTEAM_LOOKUPSunflagged, which is the shape this cut removed by hand. What the narrowness cannot do is put a registry search onto the live list in silence — a name the sweep did not derive fails the equality there. The walk isfrontend/src/test-utils/jsFunctionScanner.ts, a string-, comment- and regex-aware scan; why a regex could not do it is on the docs page - Whether every search answered and whether the panel may MOUNT are two questions, and a miss that costs less than the
panel never takes the interface off the air — check + test + prompt-only — the type carries the first half:
SteamLookup.absenceCostis required, so a new entry does not compile until it states which of the four its absence costs — thepanel; a wholefeatureoutside it (ToastRenderer,NotificationStoreandErrorBoundary, without any one of which no toast appears at all and every page, sync and download is untouched, plusAppDetailsRouteandappDetailsClasses, without either of which Steam's game page carries no Tender section); only itsappearance(ControllerGlyph, whose only consumerlayout/WidePage.tsxalready draws‹ Backin its place, andtoastClasses, whose every read is optional so the toast says what it says in an unstyled box); or only adiagnostic(playSectionClasses, read nowhere butgameDetailPatch.tsx's one-shotdumpTree, which already printsUNDEFINEDin its place) — and there is no default to arrive in.frontend/src/index.test.tsxpins both factory branches — the panel mounts with everything registered, and the miss reaches the log. Blocking is the status quo and staying there costs no evidence: nothing here is a claim that every other name was judged, only that moving one OUT needs its every consumer read, one name at a time. The join is prompt-only and spans three places:checkSteamModulesderivespanelMayMountfrom the costs,index.tsxgates the fallback page on it and logsdescribeSurvivedMisson the other side, and that sentence answers whose COPY of@decky/uiran the missed searches rather than naming a repair of its own — it used to say "a newer Tender" unconditionally, which held only while nothing reaching it was a name the package exports, andplaySectionClassesis one. Whatfrontend/src/boot/steamModules.test.tslocks is the property the line's remaining own answer rests on — a non-blocking name@decky/uidoes NOT export must be one Tender resolves for itself, swept from the source in the three shapes one is written in (afind(?:Module|ClassModule)\w*call, a direct cast ofwindowwhose exported name equals the property read, and asearchSteamFactoriesscan over Steam's module factories) — so the threeSP_*globals, which the frontend cannot attribute to a program from inside the page, fail there the moment one is made non-blocking, instead of shipping a repair aimed at whichever program did not install them.!== "panel"is the only reading ofabsenceCostthere is, sofeature,appearanceanddiagnosticrecord why a name is off blocking and decide nothing. The two things that DO answer for the toasts are prompt-only and read NAMES:notificationsMissingoverNOTIFICATION_LOOKUPSputs the notice on Main, anddescribeSurvivedMissputs the same fact in the log as a sentence stating the loss and naming NO repair of its own — the verdict sentence beside it names one that is right under every answer, which it has to be, sinceErrorBoundaryis a@decky/uiexport and a miss of it alone in the coexistence bundle isdecky. So afeatureentry added for something else cannot make either claim the notifications are what went missing, and a second spelling of any of the three cannot leave them answering for a lookup nobody asked about. Both directions fail quietly: call a real dependency cosmetic and the panel mounts and renders a hole, which is the fault the whole check exists to tell apart from a backend that is not running; call a decoration blocking and one missing glyph costs the user their entire interface, which is what this entry removed - The start-up failure page names the copy of
@decky/uithat actually ran the search that missed, and the repair that follows from it — check + test + prompt-only — the artefact's stamp is checked (frontend/scripts/check-bundle-shape.mjs, per bundle and onglobals.js, which must carry none), and the sentence behind every verdict inSEARCH_OWNERSis pinned infrontend/src/boot/steamModules.test.tsandStartupFailurePanel.test.tsxwith both bundle values exercised — the test iterates that list rather than a count, so a verdict added without a sentence on each surface fails instead of going unworded. The join is prompt-only and spans four places:rollup.config.jsserves the stamp,boot/searchingCopy.tsreads it and Decky's namespace,boot/steamModules.tswords it, andindex.tsxresolves it ONCE for the log line and the page — two resolutions could disagree with each other. The predicates belong to@decky/uiand the coexistence bundle runs DECKY's copy, so a page that blamed Tender in both would send a user after the wrong program while Decky's own interface and its other plugins broke beside it. A miss confined to names@decky/uidoes not export names NO copy and offers NO repair —SP_REACTDOMis the only one that reaches that state alone,ControllerGlyphonly ever beside a global (on its own it is cosmetic and brings no page up at all, per the entry above), anddescribeFailureanswers it before it asks whose copy ran anything. Naming a copy would blame Decky for a predicate of ours; the silence about a repair is right for the three globals and a real loss for the glyph, and only the second half of that is easy to forget. For the globals no repair follows: who installed them on a machine running both now HAS an answer — the injector loadsglobals.jsonly where Decky Loader is not serving, so beside a serving Decky they are Decky's — and this branch does not read it, because it keys on whose COPY ran the search rather than on which program installed a global. In the standalone bundle the answer would not settle it anyway: a missingSP_REACTDOMthere isglobals.jsnot having run OR our own ReactDOM predicate inboot/steamGlobals.tshaving gone stale — two repairs behind one symptom.ControllerGlyphis reached by afindModulepredicate of ours in BOTH bundles, so a newer Tender IS its repair and this branch cannot say so; restoring it here would take a third axis (whose PREDICATE, not whose copy), never a reworded answer. What bounds that cost is only that the glyph's absence costs appearance, so it never brings the page up alone anddescribeSurvivedMissprints its sentence into the log whenever it is the whole of the miss. It is NOT bounded to the company of a global: beside a blocking@decky/uiname the verdict ismixedand the glyph is that answer's unnamed rest, asking for a report rather than naming an update, with no global anywhere in the miss —steamModules.test.ts's "leaves the glyph in the unnamed rest with no global anywhere in the miss" is that case. Four quiet ways back: a runtime probe instead of the stamp (typeof DFL !== "undefined"is true of a standalone bundle loaded beside a running Decky), askingin DFLabout a name@decky/uinever exported (SP_*,ControllerGlyph— a package disagreement reported on every miss, which is whatSteamLookup.deckyUiExportand its sweep-derived lock exist to prevent), reading an unreadableDFLas an absence rather than as nothing established, and letting the reading THROW at all —definePlugin's factory reads it before it returns anything, so an unguardedwindow.DFLorname in DFLcosts the page AND the log line and leaves the blank panel the check exists to tell apart from a dead backend. The version beside the name is an enrichment only —_versionInfo.currentis internal, guarded, and every sentence is complete without it;remotebeside it is the PUBLISHED version and is never consulted - A coverage exclusion names a property of the code, never a place: every frontend-scoped entry stands in BOTH
frontend/vitest.config.ts'scoverage.excludeandsonar-project.properties'sonar.coverage.exclusions, every file entry carries its reason as a// coverage-exempt:marker in the file's own first lines, and every marked file is listed — check —scripts/check_coverage_exclusions.py. The two lists spell a shared entry differently and that is not drift: Sonar runs from the repository root and Vitest fromfrontend/, sosrc/types/**there isfrontend/src/types/**here, and the gate normalises before comparing. An entry spelled repo-relative on the Vitest side excludes nothing at all — Vitest would resolve it tofrontend/frontend/...— so it is reported by name rather than normalised into agreement with Sonar's identical-looking copy. (A folder entry is admitted only from the script'sFOLDER_ENTRIES, where membership in the folder IS the property; the backend/config entries are Sonar-only and the frontend test glob Vitest-only, each declared there with its reason so the asymmetry is stated rather than tolerated). Two accidents it removes, both silent:src/patches/**excluded a FOLDER, so a file's coverage obligation changed when it was moved out of the folder and nothing said so; andsteamShortcuts.tssat on Sonar's list and not on Vitest's under a comment claiming the two were aligned. What the check cannot see is MEMBERSHIP in a folder entry's directory — the two admitted folders are checked to exist and their contents are never read, so a real module filed intofrontend/src/types/orfrontend/src/test-utils/is exempted by its PLACE, with no marker asked for and nothing failing: the very accident above, still live for those two directories. It is declared rather than mechanized on purpose — "is this really only a type declaration" is not a cheap check, and a half-check would exempt on a property nobody stated while reading as enforcement. Nor can it see the marker's SENTENCE — a false reason passes green, which is what the three stated reasons this cut found were: "no logic to assert" over a file with four passing tests, "no isolated logic to assert" over 88.65% line coverage, and "thin plugin-entry shim" over 89.09%. Only the first was replaced by a truer marker; the other two files lost their exclusions outright, which is also how their list drift was settled - Every pinned version in a lock satisfies its
.txtsource constraint (requirements-dev.*at the root,docs/requirements.*beside the docs) — check —scripts/check_lock_sync.py - Every local markdown link in tracked docs resolves (file target + heading/attr-list anchor) — check —
scripts/check_markdown_links.py - Every RomM minimum stated for a reader matches the enforced
Plugin._MIN_REQUIRED_VERSION— check —scripts/check_romm_min_version.py. The constant inbackend/main.pyis the floortest_connection()refuses a server below; every other place the number appears is a restatement for a reader, and a restatement drifts. The check holds exactly the statements itsCLAIMSlist names — each one a narrow regex that captures the version and nothing around it, so--fixcan rewrite it in place — and fails both when a named statement says another number and when it no longer matches at all, because a regex that silently stopped matching would read as a claim that holds. What it cannot see is a restatement nobody added to that list: a new page stating the floor is unchecked until its sentence is listed there. The worked examples of the floor split three ways. The ones above the floor (5.3.1-beta,5.4.0-alpha.1) are unchecked, because no equality test fits them, and a floor raise makes them false, so they are rewritten by hand. The ones at the floor (5.3.0-beta.1,5.3.0-alpha.1) are listed and checked where a page calls them the floor's own tags, as both save-sync pages do. The conditional one in the ConnectionService notes of backend-architecture.md is not listed, because it stays true whatever the floor is. ADRs are out of scope: they record the floor as it stood when the decision was taken, so the numbers in them are history and must not be rewritten - Every tree under
backend/_vendor/is pinned by the<pkg>.SHA256SUMSbeside it: every manifest entry under<pkg>/matches the vendored file's digest, the vendored file set EQUALS the manifest's set restricted to that prefix, and a package directory with NO manifest is a failure — check —scripts/check_vendored_trees.py(the manifest is discovered, never named in the script, so the next vendored package is guarded by default rather than when someone remembers — the hole this closed was a second tree,vdf, sitting unpinned beside a pinnedatlaswhile the gate reported OK. The set-equality half is the partsha256sum -ccannot do at all: the plain form fails on a correct copy, because a wheel's manifest lists release artifacts and dist-info files we never vendor, and--ignore-missing— the flag that makes it green again — exits 0 after a vendored file is deleted). What the check cannot see is the manifest itself. Foratlasit is upstream's own release manifest, so the digests additionally prove identity with the tagged release; for a patched copy likevdfit is our own digest of the tree we ship, so a manifest regenerated to bless a hand-edit passes green and only review catches it — which is why regenerating one is the last step of a deliberate re-copy and never the answer to a failing gate. The licence assertion is data-driven, not package-driven: the sibling<pkg>.LICENSEis checked exactly where the manifest carries a dist-info licence entry, and a sibling the manifest carries no entry for is reported as pinned by nothing — otherwise regenerating a wheel's manifest from its own tree would delete the licence check in the same step, silently, while the file stayed. Deliberately outside it:backend/native/is pinned by its ownsha256sum -cover one.so, and__pycache__is ignored wherever it appears, on both sides of the comparison. Two things are outside it by accident of shape, and neither is loud. The gate sees directories only (package_dirsfilters onentry.is_dir()), so a single-module dependency dropped in as_vendor/six.pyis never asked for a manifest — and there is no shape here that could pin one: dropping asix.SHA256SUMSbeside it makes the gate fail with "it pins no vendored tree", so the guarded-by-default half is a property of package DIRECTORIES alone. Andrglobdoes not descend into a symlinked directory, so every file below one is invisible to the set comparison whatever the per-file symlink guard does; git records the link itself as mode 120000, which is what makes it a review question rather than a silent one - The release tarball is what the installer expects — one top-level
romm-tender/, the files an install starts from plus the version file, the installer an installed tree rolls back with and the licence texts a distributed copy carries, nothing the packager prunes, a sidecarsha256sum -caccepts — check —scripts/check_release_tarball.py, in CI's build job over a tarball packed from that build and in the release job over the one uploaded. It reads names, modes and digests and starts nothing; the script's docstring states what that misses - A one-time step — an installer move such as the covers' move into the cache root, a backend backfill behind a
kv_configmarker, a rung of the database'suser_versionladder or of the settings' version ladder — stays safe to run again and stays in every later release, so an update that skips releases still gets it; one leaves only deliberately, with that release's notes naming the oldest version it can be updated from directly — test + prompt-only —tests/scripts/test_install_sh.py::TestAnUpdateThatSkipsAReleaseupdates from one release straight to a later one over covers still under the data root and asserts they reach the cache root with the database and settings unchanged: it proves the covers move runs on every update, over data left in the older layout. It cannot see a step being removed from a later release, so that half is prompt-only, and nothing mechanical sees the backend's steps. Why: an update is a jump from whatever release a machine is on to the newest, not a walk through every release between them — the installer downloads one tarball, and a device that was off for a month skips every release of that month. A step that ran in 1.3 and was deleted in 1.4 is therefore never run on a machine that goes from 1.2 to 1.5, and what that step moved or filled is simply missing there, with nothing failing. Safe to run again is the other half: every update runs every step still present, so a step that assumes it has not run yet damages the machines where it has. The installer's steps are idempotent by construction (move_coversininstall.shnever moves over a file the cache already holds); a backend backfill is guarded by itskv_configmarker, a database rung byPRAGMA user_version(adapters/sqlite_migrations.py), a settings rung by the storedversion(domain/state_migrations.py). A step retired deliberately takes its floor with it: that release's notes name the oldest version it can be updated from directly - Server-supplied path components pass
safe_join(lib/path_safety.py) — test + prompt-only — traversal tests per path builder; new call sites are prompt-only - A firmware row's presence comes from the resolver wherever the resolver declared it; the plugin's own filesystem
probe covers only three leftovers — prompt-only —
services/firmware/demand.py::FirmwareDemand.is_downloadedis the single crossing point and states the boundary: the probe answers for a library file with no placement in the platform's catalogue (no emulator the resolver read declares it), for a placement whose location the plugin cannot honour, and for the already-there check before a download (the batch and the per-row fetch). Everything else reads the resolver'spresent, which follows symlinks the plugin would have to re-implement — the PS2 folder is one directory reached through two spellings.present is Nonereads as absent, the safe direction, because the row then shows work outstanding rather than a readiness nobody established. Nothing enforces the crossing point. A fourth status builder calling_firmware_file_store.exists(dest)directly would go green, and its rows would silently answer from the weaker source —os.path.existson a path the plugin assembled, which can render a satisfied requirement as missing.services/firmware/status.pyholds that store itself, for_stamp_deletable's records-still-on-disk probe, so the wrong probe is one line away from every row builder that should be askingFirmwareDemand. Related and separate: presence is not the row's verdict (CONTEXT.md → Row verdict), and a withheld verdict is not an absence — its cause is read off the row's caveat codes and, for a declared FILE, off itschecked(CONTEXT.md → Byte reading), never off the verdict itself. Three of that vocabulary's eight values sit behind one withheld verdict and are three different statements: a file the emulator READ and does not recognise was checked, so wording it "could not be checked" is untrue;refusedis not withheld at all, arriving with the verdict alreadyfalse. Nothing checks that a consumer keeps them apart —checkedis a plain string on the row beside asatisfiedthat reads like its summary - A firmware row's verdict is
BiosFileEntry.satisfied, and for a folder declaration it is what the folder HOLDS — never that the folder is there — test + prompt-only —tests/services/test_firmware.py::TestAFolderRequirementIsAnsweredByItsContentspins all three answers end-to-end,tests/domain/test_firmware_wants.pypins the fold the service asks through, andtests/adapters/test_atlas_firmware.pypins each folder answer and which codes those rows carry. The rule spans three modules and no diff-scoped review sees it whole: the adapter carriesdeclared_kindand the folder verdict — settled in the same verified per-platform reading the rest of the row comes from, so there is no second question to keep in step —domain/bios_status.py::_row_verdictdecides the row's answer, and both frontend surfaces colour and word the row off it. Nothing mechanical joins those three, which is what a consumer readingdownloadedfor a folder row breaks — anif row.downloadedbeside the verdict, a count that spends presence as readiness. RetroDECK links LRPS2'spcsx2/biosonto the BIOS root, so such a consumer reports "All required ready" over a PS2 install with no BIOS file at all; that was the state before #1807 declined the verdict, and this cut replaced the declining with a real answer, so the same field access brings it straight back. The same holds for the third value: a required row answeredNonetakes the level tounknown, and folding it intoFalseclaims an absence nothing established.declared_kindcarries a second rule with no check at all: a folder declaration is never offered as a download — the emulator lists that name, so there is no file to fetch into it. Three places refuse it today (PlatformDetail.tsx's fetchable filter,FirmwareDownloader._download_firmware_batch, andFirmwareDownloader.download_platform_firmware_file, which answers one named file and so refuses with a reason where the batch simply passes the row over);FirmwareDownloader.download_firmware(firmware_id)still does not. It is the DECLARATION's kind, so it survives an absent folder, which is exactly the case a presence check would let through - The console's own firmware demand is a value of its own (
system_image) and is never folded into a count, and the resolver'ssystem_firmware: nullreaches it as a claim about nothing — test + prompt-only —tests/domain/test_bios_status.py::TestClassifySystemImagepins all four answers and the precedence over them,::TestTheVerdictOverTheSystemImagepins what the level and the token do with each, andtests/services/test_firmware.py::TestTheConsolesOwnFirmwareDemandpins the PlayStation case end to end including that the overview and the game page stamp one answer. The frontend halves are pinned per surface (frontend/src/bigpicture/BiosTab.test.tsx,frontend/src/bigpicture/library/PlatformsTab.test.tsx). The rule spans eight modules and nothing joins them — counted one per file the answer passes through, four backend and four frontend: the adapter (adapters/atlas_firmware.py) carriesCoreFirmware.system_firmwareandrequirements_metper core,domain/firmware_wants.py::CoreFirmwareVerdictholds the four spellings apart from the absence,domain/bios_status.py::classify_system_imagedecides,services/firmware/status.pystamps it beside the counts, every frontend surface that words it does so through ONE module (frontend/src/utils/biosSummary.ts; which components those are is answered by reading them, not by a tally kept here), and a fourth reads it without wording it (below). A libretro.infocan mark a file required or optional and nothing else — no way to say "one of these", none to say the console will not start without one — so an author who knows it will not has two lossy moves, and the deployed catalogue takes both: SwanStation marks all five of its PlayStation images optional, Beetle PSX marks three of its own required. Which is why no count can be relied on to carry this: it is ONE requirement over the whole list, and putting it inrequired_countreports every image the core declares as required —0 of 5 files SwanStation requires are in placeunder the SwanStation this was observed on. The twenty in that page's own0/20 RomM library filesis the library's inventory for the platform, a different set again, and reading the two as one is how the wrong ratio gets written. Each fold fails its own way and all of them silently. Fold it into the counts and the page states a ratio over the wrong set. Readsystem_firmware: nullas "this console needs nothing" — a truthiness test, a!= "runs-without-firmware"bucket, a default — and the plugin claims an all-clear over a console nobody has looked at, which is the collapse theunknown/not_neededentry above is about, one axis over. The demand comes from the table and the presence from our rows, andrequirements_metis not consulted at all — weigh the two against each other and you have made the misreading that field exists to prevent, because ignorance there is alwaysNoneand aFalseis therefore a demonstrated statement rather than a disagreement. Its two causes (a DIFFERENT required file absent, or one present with the wrong bytes) each leave one of our own required rows unmet, so the counts already report them by name; the second needs a content check to arise, and the inventory is asked unverified (the entry below), so it cannot occur here. What the presence half actually resolves to — a row'ssatisfiedis presence,nullin two shapes, and both read as not held — is written once, atclassify_system_image, because an outside reader took that field for the resolver's usability answer and drew a false finding from it. And on the frontend,system_image: "unsettled"joinsrequired_withheldon the sidePlatformDetail'snothingEstablishedexcludes: its rows were answered, so the pane has a file list to point at rather than only a place to put files by hand. Since #1821 that flag decides WORDING alone — the download affordances are built off the fetchable set and read the verdict nowhere, because what the resolver could establish is the emulator's demand and what is fetchable is what the library holds; the two further inputs they do read (required_by_active, and the library's own finished ratio) are demand and inventory, not readiness gates. A fourth frontend reader is the play row's BIOS badge (frontend/src/utils/playSection.ts::extractBiosInfo), where"absent"is a second established absence beside the required count. Whether the count sees the same thing is the core author's choice, which is why the badge may not be left to it: under SwanStation every image is optional,required_countis 0 and the comparison beside it is vacuously false, while under Beetle PSX three of the same images are required and the count raises the badge by itself. One console, one BIOS folder, two answers — and"absent"is the same under both."unsettled"deliberately raises no badge, the same reading a withheld required row gets: the badge claims a file is NOT THERE, and nothing established that. Where BOTH ignorances hold — a console needing an image whose required folder row could not be judged, the LRPS2 shape and a reachable one —biosSummarynames the withheld ROW rather than the console. They are not two gaps over two different file sets: arequired_by_activerow always carries the launching emulator, so it is always one of the rows the disjunction is read over. It is always one of the unjudged rows that verdict is read over rather than a finding beside it — the decline needs at least one such row, and this is one — and need not be the only one, since another image the core declares can be unjudged too; it is the only half of the pair that can name a file, and naming it points at the file list, where its caveat explains itself."absent"is tested BEFORE the level's decline, and the pair never arrives at all today because the backend landsabsentonmissing. Since #1863 that order lives ONCE, inbiosSummary, which is what every wording surface reads —PlatformsTab.tsx's row tooltip last, since it kept a copy of the order and an older spelling of the states for a cut longer and described one platform in two vocabularies a keypress apart. The module's own drift lock is a test that reads components as SOURCE (biosSummary.test.ts, over the phrase list the module builds its answers from, with the ratio's twin inbiosHeldRatio.test.ts) — and since #1866 it SWEEPS the set it searches rather than naming it (frontend/src/test-utils/componentSources.ts, every non-test.tsxunderfrontend/src/bigpicture), because the naming is what failed: both locks listed two components while three rendered these states, and a surface missing from such a list carries no lock at all and cannot be told from one that never drifted. Deriving the set from who IMPORTS the module would be worse than the list — a surface wording a state for itself is exactly one that does not import it. What neither lock can catch is a component inventing a NEW wording for one of these states: only a copied phrase is searchable, so a green run there is evidence about copied sentences and about nothing else. Two limits of the sweep, both deliberate: it is.tsxonly, so a wording helper extracted into a.tsbeside its component is unsearched (frontend/src/bigpicture/panelState.tsis such a file and quotes BIOS prose today), andfrontend/src/utilsis out of scope because that is where the phrases legitimately live A narrower form of the same answer is read PER CORE onto every row (FirmwareCatalogue.emulators_needing_one_of_their_files→build_file_entry'scores[<emulator>]["needs_one_of"]and the row's ownsystem_image_candidate, worded byBiosTab.tsx'scoreLineSuffixand marked bylibrary/PlatformDetail.tsx'sdiskMark), and there the rule is that the two keys on that entry are two SPEAKERS:requiredis the core's own.info, the other is the packaged table about that core's console counted over the core's whole declaration, andoptionalbesideneeds_one_of: 5is the informative pair rather than a contradiction to resolve. Rewriting the declaration off the demand — printing "required" where the core said optional — puts words in the emulator's mouth and loses the only fact the row had to add; folding the pair the other way loses the demand. A core is in that narrower answer only where it marks NOTHING required, which is deliberate and is the second thing nothing checks: a core whose console needs an image and that does state required files says so through those rows'required_by_active, so annotating its optional rows too states one requirement twice — it put "the console will not start without one" underps1_rom.bin, which Beetle PSX marks optional while hard-requiring three other images. The same narrowing makessystem_image_candidatea strict subset of the rowsclassify_system_imageweighs, and widening either to match the other is the fix that reintroduces one of those two defects. Nothing checks any of it:needs_one_ofis a plain int-or-null on a dict a surface may read either key of, and the candidate flag is a plain bool beside arequired_by_activethat reads like its sibling - Which emulator a set of answers is about is ONE pick per scope — a platform's, and a ROM's — and every answer in
that scope is a projection of it — test + prompt-only —
tests/services/test_firmware.py::TestOnePlatformOneEmulatorasserts the two surfaces AGREE across every way a platform arrives at an emulator (no pick, each of the three ES-DE offers, a pin naming an emulator the catalogue no longer lists, a pin whose command cannot be baked) rather than pinning today's value, because a value test would pass for a third resolution that diverges on some other configuration;::TestDownloadRequiredFirmware::test_it_fetches_what_the_platforms_own_pick_calls_requiredholds the download button to the same pick. The pick isdomain/emulator_commands.py::resolve_platform_option— the per-platform override (settings.jsonplatform_cores) when its label still names a bakeable emulator, else the es_systems default — and it is the read-path precedenceActiveCoreResolverapplies minus the per-game layer. Three call sites read it today:FirmwareStatusReader._platform_emulator(which serves BOTH the overview'sactive_core/active_core_labelandcheck_platform_bios'slaunching_emulator=Nonefallback) andFirmwareDownloader._platform_emulator_identity. Nothing joins them, and a fourth resolution is exactly what this entry is about: the pane displayed a just-picked PCSX ReARMed and judged the platform by the libretro system default beside it, so one PlayStation readnot_demanded/okon the game page andabsent/missingon the pane..labeland.emulatormust come off ONE call — two calls agree by coincidence, which is what the old pair did until an override was set. One seam now carries the pick rather than a projection of it:BiosCheckertakes aLaunchingEmulator(domain/emulator_commands.py—emulatorandlabel, both read-only), so the per-game caller hands over the whole resolution andcheck_platform_biosreads both projections off that one value. A mismatched pair is not representable there, which is why the answer may state its ownactive_core_label: it is the label half of the pick those very counts were filtered by, and the game page's BIOS headline names the emulator from it (TestTheAnswerNamesTheEmulatorItJudgedBy, which hands the check two picks differing only in label and holds the name to moving while the judgment does not). That covers this seam and no other — the remaining sites still pair by discipline. The key is the emulator IDENTITY, not.core_so(#1821): the identity names a standalone pick as readily as a libretro one, wherecore_soisNonefor every standalone emulator and sent the rows back to "every declaring emulator". Reaching for.core_sohere again restores that degradation silently, because the field is still there and still right for the picker payload beside it.CoreInfoProvider.get_active_core— the "first libretro entry, bakeable or not" reading these sites used — has no production caller left. The ROM scope is the same rule one layer in, over a different pair of modules: the game page is assembled by two services that each askActiveCoreReader.active_emulator_for_romfor themselves —services/cores.py::get_platform_core_infonames the pick in the picker,services/game_detail.py::get_bios_statusscopes the BIOS question to it — and::TestOneRomOneEmulatorasserts they agree across every way a ROM arrives at an emulator (nothing pinned, the platform's pick, a per-game override, the override over a platform pick naming something else, a standalone pick, a stale pin that degrades). They read one seam today and nothing says they must; the picker reaching foractive_core_for_rom— the.so-space projection right beside it — would answerNonefor every standalone pick and send the BIOS rows back to the platform's own, which is the platform-scoped defect above, per ROM. What the ROM sibling cannot pin is the fixture's own default:FakeCoreInfoProvider.get_default_emulatorbuilds its invocation from theactive_coretuple, which carries no identity, so a test on the bare fake resolves an unpinned ROM to aNonewhere the live adapter resolves it to an emulator —_DeclaredDefaultCoreInfoin that file renders the declared default the wayAtlasCatalogueAdapterdoes, and every other fixture on the bare fake still exercises the weaker resolution - A platform's BIOS answer is asked for one platform at a time, and a row that has not got one yet is never rendered
as a row nothing could be established for — test + prompt-only —
frontend/src/bigpicture/library/PlatformsTab.test.tsxpins the four halves that can be seen from a test: the two renderings apart (an outline dot and "Checking…" against the solid grey dot and "Nothing is known"), the focused row asked ahead of the rows above it, the walk stopping at unmount, and a read issued before a core change not overwriting the one issued after it. Each was mutation-checked. The JOIN between the two calls is pinned once, intests/contract/test_firmware_status_read.py: it composes them over the real wiring and holds the result against the key set the single whole-page call answered with, which is the one thing the service tier cannot do — its ~25 whole-page tests compose through a local helper that would reproduce a composition bug rather than catch it. The rule spans three frontend modules and one backend split, and nothing joins them.services/firmware/status.pyanswersget_firmware_status(which platforms the page can speak for) andget_platform_firmware_status(one platform's whole entry — 106-486 ms each against 4.6 ms for the overview, measured);usePlatformsPageowns the walk, the per-slug ordering counter and the four-valuedfirmwareState;PlatformsTabdraws the dot;PlatformDetailwords the pane. Every failure here is silent and looks like an answer. A state-bearing field creeping back onto the overview payload gets rendered over a platform nobody has asked about yet. A fifth rendering path readingfirmware === nullinstead of the state says "nothing could be established" about most of the list for the first seconds of every visit — which is the confusion this cut exists to remove, restored by a truthiness test. An answer already held is not taken back by a later failure (firmwareStalebeside the state, never instead of it), and "the overview did not name this platform" is one of the two ways to hold one. Two halves no test reaches: thealiveguard in the hook'sacceptis unobservable under React Testing Library, which drops a write to an unmounted tree itself — what a test can see is the walk stopping, so the guard states the rule rather than being held to it; and whether an 8px outline reads as "not yet" against a filled dot is device-only, like everything else about this list's legibility - The whole-machine firmware inventory is never asked with content verification, and the per-platform reading is never
asked without it — prompt-only —
firmware_inventory()(FirmwareResolver,AtlasFirmwareAdapter) is asked unverified:verify=Truethere sweeps every unclaimed file under the BIOS root plus each declared file the packaged identity table covers at a matching size, and its two callers — the home migration's untracked-BIOS sweep anddownload_firmware(firmware_id)— need only where a file GOES.firmware_for_system(<system>)(FirmwarePlatformResolver,AtlasPlatformFirmwareAdapter) is asked WITH it, and that half is the one a reader is likely to "optimise": drop the flag and two answers go silent rather than loud — a packaged card that identifies its image by content names no file at all (DuckStation comes backdeclaration="packaged"with an empty list and no system recording), and every folder declaration's verdict falls toNone, which takes each platform holding one tounknown. Measured on the reference machine: 64-318 ms per system verified, against 248 ms for one unverified whole-machine sweep — the per-system read performs no unclaimed sweep at all, which is what bounds it. Nothing detects either direction:verifyis one keyword argument on each call and every test stays green - No sentinel objects on the wire — explicit JSON-representable tagged values only — prompt-only — no sentinel
survives on the wire today (
NO_MIGRATIONretired with #1004, legacyslot:nullconfirmation with #1276), so the rule now guards reintroduction; nothing mechanical detects a new one - Every destructive op has backup-or-confirm; never delete data that exists nowhere else — test + prompt-only —
save-file removals route through the
.romm-backupfunnel (MatrixExecutor.quarantine_local_file; the removed-game cleanup's claimed variant isPruneSaveSupport.quarantine_prune_saves); every other delete path carries the rule unmechanized. Removed-game cleanup takes the confirm leg for one case deliberately: installed ROM content the user did not select for the recovery bundle is deleted with its row. The ROM is re-downloadable from RomM where a save is not, the per-candidate opt-in and its consequence are stated in the confirmation dialog and the user guide, and the row cannot be removed at all without a fresh 404 — so this is a disclosed choice, not an exception that drifted in. The adopt dialog's replace exit is the second such case, and it does not rest on that justification: the premise is that the content is the user's own — a different rip, a patch, a romhack — which is exactly what the server cannot hand back. What carries it instead is that the user is shown both sides, offered a content check, and chooses between two named outcomes behind a second confirmation (ADR-0028). That reasoning covers the ROM only: an adoption's Overwrite also replaces save and savestate files, and those take the backup leg through the sameMatrixExecutor.quarantine_local_file— every argument ADR-0028 gives for not quarantining a ROM (gigabytes, no sensible retention, re-fetchable from RomM) inverts for a save, and a savestate is synced nowhere at all. It is the first caller to hand that funnel a directory outside the saves root: it takes the directory it is given, so a savestate's backup lands in<states>/.romm-backup/. Following a moved save directory (services/saves/save_directory.py) takes the same backup leg on a collision — detail: Following a Moved Save Directory. The installer replaces the user's database and settings on two paths, and they are held to the rule differently. A rollback by hand (install.sh --rollback) takes the backup leg: it stops the unit and copies the database files andsettings.jsonit is about to replace intorollback-backup/under the data root — put in place over the previous copy the same way as the update's backup, and never touched by an update — and a copy that cannot be made refuses the rollback with nothing changed. No prompt: the copy is what makes asking unnecessary. The automatic rollback of an update whose new version did not answer makes no such copy, because all it discards is what that version wrote while the installer waited for it, and that version was never seen to answer. Both are pinned intests/scripts/test_install_sh.py(TestRollingBackByHand, andTestAnUpdateThatDoesNotStart::test_it_keeps_no_copy_of_what_the_failed_version_wrote); what an update and a rollback do, in order: Running an installed one - A BIOS file is deleted only where a
downloaded_biosrecord names it under one of the platform's firmware slugs, and only at the path that record holds — test + prompt-only —tests/services/test_firmware.py::TestDeletePlatformBiosand::TestDeleteOneBiosFilepin every direction end-to-end: an emulator-shipped file survives, a hand-placed file under a server file's name survives, our own download is still removed once RomM no longer holds it, a download whose placement has since moved is unlinked where it was written rather than where the placement now points, and a per-row delete takes only the record it names. What makes the destructive-op rule (thebackup-or-confirmentry) concrete for BIOS files is that authority to delete comes from having placed the file, and the record is the only evidence of that, becauseBiosFile.mark_downloadedis written in the download path and nowhere else. So the records are the delete's whole input: it iterates them, not a status listing, which is also what keeps a download RomM has since dropped deletable instead of gated behind a file list that no longer names it. The authorisations a reader reaches for instead are wrong in opposite directions.downloadedisos.path.existsand nothing more: authorise on it and Delete BIOS destroys firmware RetroDECK ships with its own components, which no RomM library holds and nothing here can fetch back — it did exactly that to<bios>/dolphin-emu/Sys/codehandler.binon a real device.on_serverdescribes what the library holds now, not who wrote the file: authorise on it and a file dropped from RomM after we downloaded it is stranded on disk with nothing in the UI able to remove it. The PATH has its own version of the same trap: a status row'slocal_pathis recomputed from today's placement, so for a file fetched before an emu-atlas bump moved it the name still matches our record while the path names whatever now occupies the new destination — RetroDECK's owncodehandler.bin, in the case that motivated this. The count the UI offers is bound to the same set:deletable_counton theget_platform_firmware_statuspayload is records-still-on-disk, counted as distinct paths, becauselocal_countis the library's progress ratio and is wrong in both directions — it hid the button entirely for a platform whose downloads had all left the library. Since #1815 the same field is stamped per ROW (_stamp_deletable), and the frontend authorises a destructive action on it: a row's Delete is offered wheredeletable_countis non-zero and nowhere else, and a folder row's counts the distinct files our records name underneath it, because a folder is never a download but what we put inside one is still ours — and two records naming one path are one unlink, the platform count's own rule read one layer in. That is a wire field a page reads to decide whether to offer a delete, so deriving it fromdownloaded— the same substitution as below, one layer out — puts the button oncodehandler.bin;TestGetFirmwareStatusDeletableCountpins the row's answer for a file the plugin did not place. Three buttons now reach one removal loop (PlatformBiosDeleter._delete_recorded_io, under a record predicate per button): a second copy of that loop is the shape this rule is about, because the copies would drift silently. Nothing mechanical stands behind any of this. A delete path looping a status list ondownloadedalone would go green — which is exactly the shape this one had when it destroyed that file - Every read-mutate-write of a
RomSaveSyncStateruns underSyncEngine.rom_lock(rom_id)— prompt-only — sync paths,get_save_status, and the three slot mutations hold the lock; mechanize via arom_save_sync_states.savecall-site audit - Which files a game's save consists of is the EMULATOR's answer, read live, and four of its five states refuse the
sync — no probe, no state written — test + prompt-only —
tests/adapters/test_atlas_saves.pypins the five states and every way the question cannot be put,tests/domain/test_save_answer.pypins the precedence that makes "exactly one" well defined, andtests/services/saves/test_save_shape_gate.pypins the absences each beside a control that asserts the same probe DOES happen for a syncable answer — without those controls a service that had stopped probing entirely would pass. The answered save directory is not sync state: it lives in its own table (answered_save_directories) and may be recorded for a refusing answer, whose files are then followed when that directory moves — a move, not a sync (Following a Moved Save Directory). The rule spans seven modules and no diff-scoped review sees it whole:AtlasSaveLocationAdapterreads the machine,domain/save_answer.pydecides what the reading means,RomInfoService.save_answerturns it into names,SyncEngine's three per-ROM entry points refuse on it throughsync_engine/_shape_refusal.py, which holds the reading and the skip shape,MatrixExecutor.sync_rom_savesis the backstop every sync path crosses, andservices/saves/status/service.pyputs it on the wire. Four halves have no mechanical check at all. (1) The refusal is enforced at four call sites — the three per-ROM entry points, which report the skip viasync_engine/_shape_refusal.py'slive_save_answer/sync_refusal, andMatrixExecutor.sync_rom_saves(reached throughSyncEngine.do_sync_rom_saves), the backstop that covers the whole-library sweep, whose single result has no room to name the ROM it passed over. A fifth entry point added without either goes green, and its failure is silent because a per-game probe for a shared card finds nothing and reports "no saves". The backstop is pinned by the ABSENCE of a server round-trip, because everything downstream of it is redundantly safe — a refusing answer carries no names, so nothing is probed or grouped even without it. The five write paths refuse for themselves, each on the same answer'ssync_directory— empty for every answer a sync would not carry, a shared card with a known directory included — and withsave_shape_messagebeside the reason:switch_slot(slots/switching.py),copy_save_to_slot(copies.py),rollback_to_version(versions.py),confirm_slot_choicewith migration (slots/setup.py) andresolve_sync_conflict(sync_engine/rollback.py).test_save_shape_gate.pypins those five and nothing pins the list: a sixth write path that keys its refusal onsaves_dirinstead writes into a directory the answer never offered a sync. The same holds for following a moved directory first:SyncEngine.follow_save_directoryis called before any local file is looked at by the four sync paths, the five write paths, the two deletes and the two counting reads (count_platform_saves,get_save_status), and a new reader of local save files that skips it looks in the directory the files have just left — nothing mechanical finds such a reader. (2) A configuration-role file is excluded bySaveAnswer.synced_filesand included byowned_files, which is what a directory move must carry — a caller readingcomponentsdirectly gets neither rule, and syncing Saturn's.smpcoverwrites the console settings the user chose on the other device. Saturn is the only example that actually reaches the rule on a stock RetroDECK: MAME states a per-game.cfgtoo, but its answer classifies as not-established, so the sync refuses before any role is consulted. The rule is a DENIAL —CONFIGURATION_ROLESnames what to hold back — and turning it into an allow-list of the roles known today is the one change here that fails in silence and in the expensive direction: the resolver's ownunknownrole (a file on the machine no declaration describes) and a component with no role at all are both carried today, and an allow-list drops them, along with every role upstream names next.tests/domain/test_save_answer.pyandtests/adapters/test_atlas_saves.pypin both directions; nothing else would notice, because a dropped file is simply a file the page does not mention. (3) The two axes a rendering must read alongside the state are single fields nothing forces a consumer to touch.SaveAnswer.unestablishedholds three shapes, and a truthiness test onstate == "unestablished"collapses "nobody has audited this core" into "the folder is known and the names are not" and into "the question was never put".content_installedis worse, because ignoring it is invisible: an uninstalled ROM answers with a state, a directory and a full file list, every name a prediction about the path the game WOULD occupy, so a surface that renders them tells a user their uninstalled game already has three save files. The wire flag beside each name iscarried, notsynced, for the same reason — it names the RULE applied to a file, never that file's sync state. (4) The question must carry the ROM's REAL content path, because the answer turns on the content file's own EXTENSION — PUAE answerssave-inside-contentfor an Amiga.adfand establishes nothing for an.hdf; Genesis Plus GX answers a sharedscd_*.brmfor a Sega CD.chdand a per-game.srmfor a.bin. WithinRomInfoServicethe system and the path are decided in exactly two places —_installed_answerfor a ROM on disk and_uninstalled_answerfor one the library only knows about — so ADR-0010's slug leak has two sites to guard there rather than one per caller. A third site exists outside it:services/rom_adoption/renamer.pyasks the resolver directly for the save and the savestate directory of both launch paths of a rename, taking the system off the adoption target the service resolved — so it cannot leak the slug, and it is a site the same rule has to hold at. A synthetic stem passed anywhere else answers a different question in a shape that looks like an answer to this one, and nothing would say so. It is also why every per-system pin intests/adapters/test_atlas_saves.pyis keyed by(system, extension): a pin that does not name the extension it asked with is pinning nothing, which is how two independent measurements of the same systems produced contradictory fact lists. Every path asks live and nothing caches an answer — only the installation handle is memoised — because the user changes a core's options in the emulator's own quick menu between a launch and the next sync; a display cache added without invalidating it on every sync entry is the one change that makes this rule fail silently and expensively. Detail:docs/architecture/save-sync-coverage.md, CONTEXT.md → Save state / Save scope - Per-slot server reads/deletes go through
domain/save_slot.py(legacy omits&slot=, client-filters) — prompt-only —get_slot_saves/get_slot_delete_info/delete_slot/list_file_versions/rollback_to_versionuseslot_query_param+save_in_slot; RomM can't addressslot:nullvia the param, so legacy MUST omit it + filter client-side, and a legacy delete is refused up-front - Every save-sync decision comes from
compute_sync_action(vialist_saves), never thenegotiateop list; every automatic upload POSTsoverwrite=false(409-backstopped);overwrite=trueonly from an explicitkeep_local— test + prompt-only — the hand-enumerated core cases (tests/adapters/test_gavel_native_decision_table.py) and the core property tier (tests/adapters/test_gavel_native_property.py) + contract 409 tests (tests/contract/); new upload/dispatch call sites are prompt-only - Both save-sync decisions run in the compiled gavel core, reached only through the
ComputeSyncActionFn/ResolveUploadConflictFnseams;domain/sync_action.pyholds only theSyncActionvocabulary the core answers in, so a change to either decision is a contract change carried by re-copied gavel vectors — test — both vendored vector families run against the core (ladder intests/adapters/test_gavel_native.py, decision table intests/adapters/test_gavel_native_table_vectors.py); the.soand the vectors are pinned to the same upstream release tag and are bumped together applied_launch_optionsis written only by the six recorded-state writer sites (sync ack-commit, download-complete, adopt-complete, uninstall, home-migration, version-switch), each recording the exact command the frontend wrote; excluded from the sync UPSERT; the only sanctioned reset is Force Full Sync's clear-to-NULL (a wrong recorded value is the only path to a wrong delta-skip) — test + prompt-only — each writer site carries a value-exact test; new launch-options write paths are prompt-only — mechanize via aset_applied_launch_options/record_applied_launch_optionscall-site audit. Download-complete and adopt-complete are one site in the code (RomInstallRecorder.do_record_applied_launch_options) and two in the flow, because an adopted install is an install in every respect (ADR-0028)- An abandoned-chunk stash's whole-unit apply staging (
pending_sync/pending_all_roms/pending_cover_sources) is never mutated while the stash is pending (box IDLE) — every run-entry path passestry_begin_run, which clears the stash before any staging write — prompt-only — the invariant holds today rather than being aspirational; mechanize via a staging-writer call-site audit - An apply chunk's ack identity —
active_unit_id/active_chunk_indexand a freshunit_complete_event— is stamped on the box BEFORE that chunk'ssync_apply_unitis emitted, with nothing awaited in between — test + prompt-only —tests/services/library/test_chunk_dispatcher.py::TestAckIdentityPrecedesTheEmit, which wrapsChunkDispatcher._emitand records the box at call time over a two-chunk unit. The rule spans three modules and no diff-scoped review sees it whole:ChunkDispatcherstamps,services/library/_state.pyholds the fields and their verbs, andSyncReporter.report_unit_resultsvalidates an incoming ack against them (#1041). What the test pins is the ordering inside the dispatcher, not the round-trip — it observes a mock's call-time state, so a real frontend ack racing a real emit is still unexercised, and every other suite lets the emit mock swallow the call. The failure mode is why the entry exists rather than being left to the comment: stamp after the emit and a fast ack is rejected as stray, the wait then stalls the full 60-second heartbeat window, and the run ends by stashing a chunk the frontend had already applied — slow, plausible-looking, and silent (#1052 / #1367) - Every path on which the user answers the preview question leaves a live snapshot on neither side — the
pending-preview store (
frontend/src/utils/pendingPreviewStore.ts) and the backend'spending_delta— prompt-only — three paths clear the store and tell the backend, and all three are the Sync page's: Apply (applyPreview), Cancel (cancelPreview) and Refresh (computePreview(true), which discards before asking for the next one). The fourth is the cancel that lands just after a preview was staged, which never adopted it into the store and so discharges the rule by discarding server-side alone. A fifth path is not an answer at all and is held to the same rule: a successful Force Full Sync (forceFullSync) discards the state the preview was computed against, so it clears the store and tells the backend too — a preview left standing there offers an Apply that would skip exactly what the clear armed a re-fetch for. Main holds none of them, and holds none of them for a stronger reason than a division of labour: it starts no run and computes no preview, so it never holds one to answer for — its slot opens the page, and its Cancel ends a run rather than answering a preview. So the answer is given once, where the change table is. Nothing mechanical can tell: an answer path is a page handler, and neither the store nor the backend can know that a call it never received was an answer. Forget the store and a table stands over a decision already made; forget the backend and the terminal-stage re-ask fetches it back a round trip later - A prune run's claim reservation and its refusal of every conflicting callable happen in one atomic gate hold (the preview rebuild does not), and frontend-owned Steam work holds a heartbeated, generation-tombstoned lease through every continuation's final write — test + prompt-only — prune service/gate race tests + contract callable-entry matrix; new conflicting entry points are prompt-only
- A removed-game cleanup's run claim is registered on the prune conflict gate before the start's reservation is given
back, so the two windows overlap and no conflicting endpoint runs in a gap between them — test + prompt-only —
tests/services/prune/test_service.py::test_a_started_run_holds_its_claim_on_the_gate_until_it_endsandtests/contract/test_prune.py::test_a_cleanup_refuses_conflicting_endpoints_from_its_start_to_its_end. A gap would let a conflicting endpoint change local state after the start revalidated its preview and before the run acts on it. The reservation belongs to the decorator, which gives it back in itsfinallyoncePlugin.start_prunereturns; the run claim belongs to the prune service, which registers it instart_prune's second lock hold, where it sets_run_id. The order holds only because the second happens inside the first's call. Prompt-only:PruneService.start_pruneis reached only through the endpoint marked@prune_exclusive_start— a caller that reached it another way would skip the refusal on held operations and leases, and would run the start's validation with no reservation in front of it, open to every conflicting endpoint that could change the local state the refreshed preview is checked against. The service test checks that the run is registered by the timestart_prunereturns, which is what keeps the order; the contract test holds a real start inside its preview rebuild and checks the refusal during validation and after the start returns, and its lifting once the run ends — it does not see a registration moved into the run task's first step, because that step runs before the start's caller resumes. Neither sees a second caller - A prune frontend action mutates Steam only after atomically claiming its exact run/token/discriminant/binding;
repeats are idempotent and an outcome lost in transit is ambiguous, never success — test + prompt-only — prune
service claim tests +
frontend/src/utils/pruneActions.test.ts; new action kinds are prompt-only - Every installed-content mutation is authorized by a descriptor-relative no-follow claim (root identity, descendant
identities, and — where a bundle exists — regular-file hashes) revalidated immediately before it, never by a path
re-lookup; refusal, partial mutation and ambiguity are reported, never rewritten into success. The hashes bind a
deletion to bytes held somewhere else, so they follow the bundle, not the caller: a source a sealed bundle holds
consumes the bundle's digest-bound claim, a source it did not capture seals a fresh content-bound one, and a removal
with no bundle anywhere — recovery off, or a user-initiated uninstall — seals identity-only
(
claim_source(..., digest=False)). An identity-only claim is also the only one that may adopt interrupted.{basename}.romm-prune-*staging, and only where a surviving install row proves the path. Everything else holds for both: staging rename, mount checks, no-follow traversal, and exact-identity revalidation under writer exclusion held across each unlink. The one guarantee that differs is all-or-nothing: a content-bound removal leases the whole tree up front, an identity-only directory leases per unlink (a whole-tree hold hitsEMFILEand makes large dumps un-uninstallable), so a writer arriving mid-loop yields a reported partial removal instead of a clean refusal — test + prompt-only — descriptor-path, recovery-adapter, real RomRemovalService, and prune contract tests; new mutation adapters are prompt-only - Every prune frame carries its originating preview ID; only a matching pending preview may adopt a run, and an
accepted contiguous terminal result seals it against every later frame — test + prompt-only — prune service frame
tests +
frontend/src/utils/pruneStore.test.ts; new prune frame types are prompt-only - Every destructive RomM proof is bound to one canonical server-origin/token-origin/user namespace from preview through every exact-ID request; a namespace change is uncertainty, never a 404 deletion authority — test + prompt-only — prune service namespace-race tests; new destructive RomM proof paths are prompt-only
- Every write into per-rom detail state that crosses an
awaitis bound to a rom identity — the store (frontend/src/utils/gameDetailStore.ts) viawriterForRom, or the answer's ownrom_idinapplySaveStatus; the panel's state, event and tab-content modules (frontend/src/bigpicture/panelState.ts,frontend/src/bigpicture/panelEvents.ts,frontend/src/bigpicture/panelTabContent.tsx— the panel component itself holds none of these writes) viaRomBinding, built bybindRomfor a read a run of the[appId]effect issued and bybindRomInStatefor the active tab's panes, whose writer is built during render; the achievements tab (frontend/src/bigpicture/AchievementsTab.tsx) by construction, its React key being the rom id, so its state cannot outlive the identity it was read for. A version switch re-keys without closing, so neither the store's generation counter nor the panel's[appId]effect sees this class. Binding answers the wrong-rom question only; two answers for the SAME rom are ordered instead, by a sequence taken when the read is issued — the store'sloadSeq, the panel'stakeReadTicket(#1717). Four writes are unbound. Three are ordered: the two identity writes install what a binding would compare against, so ordering is all they can have, and the panel's lazy SAVES-tab slot load (frontend/src/bigpicture/panelSlotsLoad.ts) writes through the raw setter, ordered by its ownslotsticket. The fourth has neither — the store'scached.bios_statusfold runs in the same synchronous run as its guard. The event lane'shandleBiosChangeis bound and ordered: it re-readsget_bios_statusfor the rom it shows, whose emulator is resolved per ROM — a per-game pin over the platform's pick — and takes thebiosticket the panel's other BIOS re-reads take (#1718). The play button is NOT covered (#1714) — test + prompt-only — the panel's fourteen bound sites each carry a version-switch test (frontend/src/bigpicture/RomMGameInfoPanel.test.tsx); the store side and every new write site on either are prompt-only, because a checker scoped to the store's own function bodies would be green on the case this rule was written for. The reasons behind the two writer mechanisms live atwriterForRomandRomBinding— do not restate them here - Every row a reader must be able to reach on a QAM page is a row Steam can focus — a toggle, a button, or a
Focusabledeclaring a stop of its own, including a table row with no action of its own, so the reader can walk the table — check + prompt-only —tender/qam-focusable-rowchecks the narrow syntactic slice where an@decky/uiFocusablein the QAM module map has no nav-stop prop, static focusable descendant, opaque child, or unknown spread; focus order, runtime reachability, edge revelation, scrolling geometry, and controller behaviour remain prompt-only. The frontend suite cannot see those runtime properties: happy-dom has no nav tree, so a page whose rows are unreachable renders exactly like one whose rows are not, and a mouse-driven dev loop never meets the problem either. A region scrolls only by moving focus — Steam's plainScrollPanelbinds no gamepad direction — so an unreachable row is also an unscrollable one, and everything below the fold is simply out of reach with a controller. The trap is that a bareFocusableis a container rather than a focus stop: Steam's navigation asks the nav node the panel renders (GetFocusable()), which finds no stop on a row declaring none of the four options it reads — nor on one whoseonActivate/onOKButtonwould have promoted it, since that promotion is skipped where an option was supplied — all stated in full atdocs/architecture/qam-panel.md, which owns that mechanism. What the check cannot see it says nothing about, and three shapes matter. One opaque child anywhere among a row's children passes the whole row, which is how the removed-games cleanup's details region stood unreachable in the one release that has shipped with this gate green — aFocusableof plain text, fixed by declaring a nav option on it and not by the rule. A descendant the BROWSER can focus — atabIndex, abutton— is taken as an escape, so a row reachable with a mouse and not with a stick passes. And a row whose owntabIndexis its only affordance is reported rather than accepted, because that attribute reaches the rendered element and not the node. The rule spans every page the wide frame will host, and the frame cannot carry it: it holds a page's content as opaque nodes, never as rows it could check. What the frame DOES carry is the content outside a region's focusable rows, at both ends — a heading, a counts line or a column header above the first, a legend or a total below the last, each unreachable for the same reason and with no neighbour to ride along with — soScrollRegionscrolls itself to the top when focus reaches the first stop in it and to its end when focus reaches the last (revealEdge, overrevealTopandrevealBottom). Both halves are pinned byfrontend/src/bigpicture/layout/ScrollRegion.test.tsxover mocked geometry, so what is tested is the DECISION and not the scroll: whether the panel and the reader agree about which element is topmost or last stays device-only, like the rest of this entry. Reachable is not near, and the same mechanism decides where a page puts its controls: focus moves one row at a time, so a button under a list of N focusable rows is N presses from the top of the column — sixteen, measured on the device for the Cancel that stops a sixteen-unit run. That is why the Sync page's two button rows sit ABOVE their tables, which is the reading the layout wants anyway: what you can do, then why. Nothing checks that half either, and the suite is blind to it for the same reason — a page whose only control is a library's length below the point it opens at renders exactly like one whose control is a press away. Detail:docs/architecture/qam-panel.md, "Building blocks" - A list-and-detail page opens on the row it was opened WITH, not on its first row — because on that layout focus
selects, so entry focus landing anywhere selects what it lands on — test + prompt-only —
ListDetail.test.tsxpins the mark and that it moves with the selection,WidePage.test.tsxpins that the frame asks for the declared stop, and both use a non-first row deliberately. The rule spans three modules and nothing joins them:utils/entryFocus.tsownsENTRY_STOP_ATTRandpageEntryStop,bigpicture/layout/WidePage.tsxplaces entry focus through it rather than throughfirstBodyStop, andbigpicture/layout/ListDetail.tsxmarks its selected row — plus whatever page passes a starting selection at all,SettingsPagetoday. A fourth wide page that opens on a non-first row and forgets the mark selects its first row instead, and every test still passes. The end to end is unreachable here: happy-dom performs no layout and does not reproduce Steam's focus resolution, so what the suite pins is the declaration and the finder, never the press. Only a controller confirms it. What hid this for a whole review round is the shape of the failure, not its size: Main's notices name sections, and the one naming the FIRST section keeps working, so a reader checking Open Connections sees the feature working while Open Controller lands on Connections. A check that exercises the first row proves nothing about the rule. The mark sits on adisplay: contentswrapper AROUND each row rather than on the row, and that is load-bearing rather than stylistic:pageEntryStopcallsfirstBodyStop(declared), which searches DESCENDANTS — a mark on the row itself finds no candidate inside it, falls back to the first row, and ships the defect under a comment saying it does not