Skip to content

The bundled app had no frontend in it, and 49 other commits from one audit - #36

Merged
marcoasjunior merged 51 commits into
mainfrom
fix/capability-engine-integrity
Oct 1, 2026
Merged

marcoasjunior merged 51 commits into
mainfrom
fix/capability-engine-integrity

Conversation

@marcoasjunior

Copy link
Copy Markdown
Collaborator

What this is

The accumulated output of one audit pass and four roadmap rewrites. 50 commits,
main..fix/capability-engine-integrity. The map is docs/AUDIT-2026-09-29.md
and the rewritten ROADMAP.md; this body is the short version and
flags what a reviewer cannot infer from the diff.

It is large on purpose. The alternative was a series of small PRs against a
roadmap that no longer described the code, which is the state the repository was
in when the audit started.

The one thing to read first

The bundled app had no frontend in it. The last three commits close it.

src-tauri/Cargo.toml declared tauri = { version = "2", features = [] }.
Without the custom-protocol Cargo feature, tauri's build script takes
let dev = !custom_protocol and emits cargo:dev; tauri-build runs
cfg_alias("dev", is_dev()) for the whole crate; and Manager::get_app_url
resolves the app URL to build.devUrl — http://127.0.0.1:1420 — instead of
the embedded frontendDist.

Nothing announces it. cargo check passes, cargo build --release passes,
beforeBuildCommand runs, dist/ is produced, the installer builds, installs
and launches. The only symptom is a blank WebView2 on a user's machine.

It was not a theory. Two release build directories on the authoring machine,
same tree, same day:

build time state embedded frontend
moddin-desktop-05b7570174302b52 02:25 cargo:rustc-cfg=dev none — no tauri-codegen-assets
moddin-desktop-8570ac569a8989ff 18:06 dev=false 4 files, 200.5 KB

The second is after the fix. Those 4 files are the brotli-compressed form of the
4 files in dist/ (761 KB raw), so the correspondence is exact.

The fix arrived on feat/agent-mcp as ef9553c and was never merged, because
the roadmap read that branch as product work pending a port rather than as a fix
belonging on the main line. A merge is not the only way a commit reaches a
release, and "not in main" is not the same as "not needed."
That branch has
since been deleted after every file in it was accounted for.

scripts/check-bundle-features.mjs now gates the manifest, walking the
repository for Cargo.toml files rather than naming one, and it is proved to
fire by mutation. docs/UPDATER.md's dry-run procedure now requires the
artifact check before a tag, because the old procedure said "install it on a
spare machine" and never asked whether the window rendered anything.

What else is in here

  • Trust. The community key is pinned against a compiled keyring; the cached
    catalogue is re-verified on every read instead of trusting a meta.json flag;
    the YAML fetch host is allow-listed; revocations now travel inside the signed
    catalog.json, so one signature covers both halves and they cannot disagree.
  • The capability engine installs what it advertises: the transaction opens
    before the step loop, steps are constrained to the install root, the four step
    kinds the last two recipes needed exist, and three gaps found this cycle are
    closed at the tip — an include/exclude member filter on extract-zip,
    %VAR% expansion with a JSON-safe write-text-file, and a refusal when two
    archive members claim the same proxy name.
  • Delivery. release.yml builds what it signs, the staged runtime installs
    from a lockfile, the installer is 23% smaller, and fmt and clippy block.
  • Gates. 336 cargo tests, 379 frontend tests, a catalogue validator, a
    frontend architecture guard, a parity guard comparing four vocabularies across
    five sources including param names, compile-enforced locale parity, and CSP.
  • Frontend. App.vue decomposed by responsibility, one dialog
    implementation, one error path, one empty-state contract, a localization
    boundary and an accessibility pass.

ROADMAP.md records the reasoning for each item, including the ones closed as
written because their premise was stale, and the ones left open deliberately.

Verification

cargo test 336 passed · cargo clippy --all-targets -D warnings zero warnings,
no new #[allow] · cargo fmt --check clean · npm run test 379 passed ·
check:capability-kinds, check:catalog, check:architecture, typecheck,
check:bundle-features all pass.

Needs a maintainer, and blocks the release

  1. Re-sign the community catalogue. catalog.json changed twice during this
    pass. regenerate_catalog.py refuses to sign without MODDIN_SIGNING_KEY.
    Until it is signed the app correctly refuses the community fetch.
  2. The updater pubkey. tauri.conf.json still holds the placeholder, and
    TAURI_SIGNING_PRIVATE_KEY / ..._PASSWORD are not repository secrets yet.
    A release build refuses with E0080 until the pubkey is substituted — that is
    the documented fail-closed path, not a regression.
  3. Then a dry-run tag (non-prerelease, or the updater will not see it),
    checking the artifact as docs/UPDATER.md now describes.

No installable artifact has been produced from this branch. The engine
defect is fixed and measured; that an installed app renders its UI is unproven
and needs steps 2 and 3.

Also unproven until this PR runs: nothing on a fix/** branch triggers CI, so
every gate above was run locally, not by a workflow.

Three documents told users to look for an .msi installer. The
bundled output is *_x64-setup.exe, produced by the NSIS target in
tauri.conf.json.
P0-1: extract-zip resolves its archive with resolve_download_path,
which only special-cases a bare filename. Five registered specs
pointed archivePathField at a URL, so every one of them failed at its
first install step. The download-file step existed; it had been added
to the one spec that was not registered. optiscaler, reshade and
cheeky-foveated-dlss now fetch into the download cache first, and the
proxy rename uses extract-zip native proxyField instead of a
move-file that pointed a URL at a rename.

ofxr-bridge and uevr stay status: planned rather than fixed. They
need step kinds the engine does not have: ofxr-bridge promotes one
named file from a staging directory into the OpenXR ApiLayers folder,
and uevr ships as a git tag that must be built. Each carries a
safetyNotes entry naming the exact capability it needs.

P0-2: the transaction was opened after the step loop, so a file the
archive overwrote was snapshotted in its new state and a mid-install
failure left orphaned writes with no rollback record. execute_install
now plans each step targets, extends the transaction before the step
runs, and rolls back on failure. Covered by
an_install_backs_up_an_existing_game_file_before_overwriting_it and
a_failed_step_rolls_back_what_the_install_already_wrote.

P0-3: bepinex, ue4ss and reframework are now registered. The
six-spec list became one BUILT_IN_YAML const shared by load() and
load_with_local_dir(), which had drifted apart.

F-02: a step cannot traverse out of the install root. Absolute paths
from path-typed config fields stay allowed, because those fields
exist precisely to name a location outside the game folder; what is
refused is .. traversal, and the transaction store still declines to
back anything up outside the root.

Also fixed in passing: spawn-process no longer puts pid:NNN in
affected_paths; move-file rejects a source equal to its destination;
the YAML parse panic names the offending recipe, because a scalar
containing a colon-space silently becomes a mapping; and
test_support::env_lock recovers from poisoning so one real failure
stays one failure instead of twelve fake ones.
F-03: pinned-public-key.bin was returned unverified, so any 32 bytes
written there became the key the community catalog was checked
against. It is now validated against a compiled keyring
(TRUSTED_PUBLIC_KEYS_B64) holding the active key plus the one retired
in the 2026-09-24 rotation. A pin that does not match is discarded,
logged, and replaced by the anchor. REVOKED_KEY_FINGERPRINTS is wired
for a key that turns out to be compromised.

Four tests cover this, including one that asserts its attacker key
really decodes, so it cannot pass vacuously.
P0-5: both AI entry points passed a catalog id while
resolveInstallTarget matched it against a store app id, so every
install ended at "no game selected". Added
findInstalledGameForCatalogGame, the inverse of the existing
findCatalogGameByInstalledGame, and renamed the parameter to
catalogGameId so the contract is explicit.

P0-6: capability cards rendered capability.id because the description
field did not exist, on either Rust struct, in either TypeScript
interface, or in any built-in YAML. The card now shows a human
description and falls back to the id only when a recipe declares
none.

UX-12: the AI install path hard-coded acceptUnsigned: true, so the
consent the Community panel asks for was skipped. It now splits the
batch by the verified catalog signed flag and fails closed on the
unsigned ones, naming them. Consent is persisted per capability id.

UX-11: capability removal, the Windows-wide OpenXR switch, Local AI
disconnect and history undo were unconfirmed destructive actions. All
four now go through a new ConfirmDialog built on BaseDialog, so the
focus trap, Escape, focus-restore and aria-modal are inherited
rather than re-implemented. Each names what is about to change and
what it costs to undo; the history one counts the files, which the
Desfazer button never did. Adds .btn-danger-solid, which the design
system did not have.

F-06: the TypeScript step-kind union declared 5 of the 11 kinds the
runner implements, and the AI/MCP authoring path types recipes against
it. Now 11, with a comment naming the parity guard.

None of this is covered by a test: the project still has no frontend
test runner, which is F-12.
P0-8: the release job published a hand-typed SHA-256 next to an
installer it never built. release.yml now runs a windows-latest job
that checks out, runs npm ci, builds the frontend, bundles the
runtime and produces the NSIS installer, then hashes the artifact it
actually found. The release job needs that build and appends the
CI-computed digest to the release body, so the digest and the file
can no longer disagree, because they come from the same run.

O-06: bundle-runtime.ps1 now fetches Node published SHASUMS256.txt,
refuses to install an archive that is not listed, and aborts on a
digest mismatch instead of extracting it. In a product whose pitch is
that it verifies what it installs, the shipped artifact was the one
download that was not checked. The script header also no longer
claims Node is optional while step 3 shells out to npm install.

F-05: a list of valid step and check kinds lived in four places
across three repositories, and nothing kept them in sync. The drift
was found by a human auditing the source, twice.
scripts/check-capability-kind-parity.mjs reads all of them and fails
the build on any disagreement, the same way the architecture guard
does. Verified by deliberately removing download-file from the Python
set: the guard failed, and passed again once restored.

The community capabilities submodule pointer moves to two commits
fixing the fps-unlocker install chain and adding the three validator
rules that catch its class.
dead-island-2.yaml declared enginePreset: re-engine while shipping
UEVR, OFXR and Cheeky modules that the re-engine preset own
description says it does not target. Dead Island 2 is Unreal 5.

SCOPE.md claimed VR launch profiles for five games (two have one),
listed ReShade host and Auto-update as shipped (neither is reachable
from the UI, and there is no self-updater at all), and called
transactional Undo stable when it held on one of two code paths.

references/game-support-matrix.md listed four games and omitted
Elden Ring and Cyberpunk 2077, the two flagship titles. It is
regenerated from the six catalog YAMLs and keeps the caveat that
nothing validates a wrong executable or enginePreset, which has
bitten this file twice.
The previous ROADMAP.md was a changelog with checkboxes. It marked
four things done that are not done, marked two things open that are
shipped, and omitted an entire shipped product line: the AI
assistant, the MCP agent, the community catalog, and PCGW/GOG
discovery appear nowhere in it.

It is replaced rather than amended. Every item now cites the audit
ID it comes from, and carries a Done when line that says what makes
it reviewable. Effort is marked. The corrections table records what
changed and why, so the next person does not re-derive it.

docs/AUDIT-2026-09-29.md holds the evidence: 8 P0, 29 P1, 22 P2,
each with path:line, marked verified when the code was read and risk
when it was inferred. Three fix passes are recorded there as they
landed, including the two decisions worth reviewing (ofxr-bridge and
uevr marked planned rather than faked) and the two things a
maintainer has to do by hand.

Bearing in mind the standing constraints: the signing key is still a
Rust constant, so rotation remains a binary release; App.vue is at
93.6% of its CI budget; the bundle step gates cargo test, so every
Rust check depends on nodejs.org being reachable.
Six documents made claims the code contradicts, the repo tracked
generated and wrong-platform weight, and the conventions the history
already follows were written down nowhere.

Corrected against the code:

  - MODULES.md taught the authoring pattern beta.5 fixed: an
    extract-zip reading archivePathField from a URL, which
    resolve_download_path turns into <game dir>/https:/... The
    example is now download-file then extract-zip against the cached
    file, with extract-zip proxyField as the native proxy rename,
    and a section on why the older pattern cannot work. It also
    claimed a bespoke Rust module is a peer of a capability; the real
    direction is that reusable work becomes a YAML capability and
    Rust only adds a step or check kind the engine lacks.
  - tools/README.md contradicted MODULES.md on exactly that question.
    Resolved in favour of the capability, which is what SKILL.md and
    all four agent templates already prescribe.
  - DEVELOPMENT.md did not document bundle-runtime.ps1 as a
    prerequisite, even though tauri-build validates those resource
    paths during cargo's build script and both cargo test and cargo
    check fail without it. This bit CI, where the step exists only
    with a comment explaining why.
  - MODULES.md also pointed at scripts/validate_capability.py inside
    this repo (it lives in the community submodule), listed ReShade
    as reachable from the UI when no invoke call reaches it, and
    linked an anchor that does not exist.

Hygiene:

  - Untracked the root catalog.json (regenerated from the YAMLs, and
    never read from there at runtime), the orphan baseline .zip
    whose five entries are byte-identical to the tracked files under
    tools/, the macOS and iOS icons for a Windows-only product, and
    the tauri-build generated schemas.
  - Removed docs/MERGE-NOTES.md, .gh-pr-body.md and
    scripts/fix-locale-mojibake.py. Nothing referenced them.
  - Moved five root-level release notes into docs/release-notes/ with
    an index, so they have a home and a stated current version.
  - Added docs/README.md as an index of every document and what it is
    for.
  - Added docs/BACKEND-MODULES.md, which settles which of each
    src-tauri/src/* vs *_module.rs pair is authoritative. Five of the
    adapters are unreferenced and updater.rs has never had a mod
    declaration in any commit, so it has never been compiled.

Conventions (CONTRIBUTING.md) are counted from the real history
rather than asserted: branch prefixes, the commit types actually used
over the last 37 non-merge commits, and what a commit body owes the
reader. Two things it surfaced rather than smooths over: chore/ has
never been used for a branch, and ci.yml only triggers on main,
feat/** and refactor/**, so a fix/ or hardening/ branch gets no
feedback until a PR.
…pes needed

ofxr-bridge and uevr were left status: planned because the engine had
no step kind they could use. Both are wired now.

extract-zip gains targetSubdir, so an archive can be staged and one
named file promoted out of it. Absent, the behaviour and the
affected_paths strings are identical to before, so no existing spec
changes meaning. move-file can move a directory, and refuses a
destination that already exists: Undo could not restore a directory it
never copied, so replacing one silently is worse than stopping.

git-checkout clones and checks out an exact tag or full SHA. A branch
name, HEAD, a short SHA and anything git itself refuses are rejected,
so "pinned" means pinned. It refuses to delete a checkout directory
unless that directory's own recorded origin matches — the same "do not
touch what we did not create" rule ofxr.rs uses. build-project runs a
command in the tree and verifies each declared output exists
afterwards, reusing the process runner spawn-process uses rather than
writing a second one.

ofxr-bridge is available: download, extract to staging, promote the
manifest, write the tray, start it. Its safetyNotes say the promoted
manifest name is the one element this repository cannot verify, and
that a wrong name fails loudly and rolls back rather than installing
nothing. uevr stays planned — the chain is correct, but UEVR publishes
prebuilt archives from three different repositories, so its backend
enum is a repository matrix that one checkout and one build command
cannot express. Its safetyNotes say so.

SKILL.md no longer claims Moddin rejects HKLM. What is enforced is
that a step cannot traverse out of the install root and that the
transaction store will not back anything up outside it; absolute paths
from path-typed config fields are deliberately honoured, because those
fields exist to name a location outside the game folder.

19 new tests; 252 pass.
…ocation

The cached catalogue was the weakest link left in the trust chain. The
pin is now validated against a compiled keyring, but
community_capability_install still resolved its download URL from a
cached file, and fetch() copied a signatureVerified flag out of a local
meta.json any writer could set.

The cache now stores the raw fetched bytes and the signature beside
them. read_verified_cached_catalog verifies against every non-revoked
key in the keyring on each read, and the install path goes through
that single gate. Missing or corrupt signature is a cache miss, not a
fallback. signatureVerified is now derived from an actual
verification instead of being read from disk.

The YAML fetch had no host allow-list, unlike download-file. It does
now, and the default adds raw.githubusercontent.com because every
downloadUrl in the catalogue points there; matching download-file's
list byte for byte would have made community installs impossible
rather than safer. A passed list replaces the default and never widens
it.

revoked-ids.json is read, cached, and enforced. The kill switch is
consulted before the signature, deliberately: a tampered list can only
add refusals, never remove one. No list, an unreadable list, a non-2xx
or unparseable content all fail closed. A network failure falls back to
the cached list, which is what SECURITY.md promises; a corrupt list
does not, because a bad upstream file is not masked by a stale one. A
revoked capability stays visible with the maintainers' reason rather
than silently vanishing.

F-14: every registered command now has a caller or is gone.
capability_reload is wired, so a locally dropped recipe shows up
without a restart. get_compat_report, the four snapshot commands,
reshade_module, reshade, library_state and the orphaned snapshot
helpers are gone. validate_recommendations_yaml is kept — the audit
was wrong, it is called from the AI assistant.

Two of those deletions are worth stating plainly. ReShade's Rust
module was unreachable while the declarative path was live and
registered, so it was redundancy, not a loss. GOG was the opposite:
refresh_installed_games_async was the only caller of
detect_gog_installed_games_blocking and had no caller itself, so GOG
discovery shipped in the build and was unreachable in the app. The scan
now runs inside detect_installed_games_blocking, which the UI calls on
load, matching what steam.rs already claimed in its own comment.

19 new tests in the module. The tampered-cache test signs with an
attacker keypair and asserts the signature really is valid for the
payload before asserting rejection, so it cannot pass vacuously, and
it asserts the unverified read does return the attacker's URL — so the
refusal is provably the gate working rather than a broken fixture.
Four hand-rolled dialogs, three copy-pasted error classifiers and
eleven hand-rolled empty states collapse into the three components the
design system already had.

BaseDialog is now the only dialog implementation, and it is the only
one carrying a focus trap: it delegates to useDialogLifecycle instead
of keeping a fifth copy. The four panels lose their own overlay,
backdrop, Escape and focus code. A keyboard user now lands in the same
place in all six.

useFriendlyError and ErrorCallout replace three classifiers and ten
raw err.message dumps, each with deliberate copy in all three
languages. It takes a context, because the audit's shape could not
tell "could not fetch the catalogue" from "could not install the mod" —
both arrive as a bare message, and mapping both to the fetch fallback
would have announced a fetch failure on an install failure. Three
already-localized community messages are passed as verbatim so they
are not re-titled as failures.

EmptyState, 11 conversions. The capability section finally renders the
loading state its composable was already maintaining.

Capability cards derive their state from verification and busy flags,
so a failed check no longer looks like a healthy module, and the
section passes verification to ModuleCard instead of rendering a second
copy of the checklist. The card's prop is narrowed to the checks it
actually renders, which let the section pass the real report without
faking fields it never has.

Sidebar tools sit under two labelled groups and IA local gets its own
glyph. The five AI entry-point verbs collapse into one, naming modes
inside the dialog.

AiTopbarMenu and AiGameSuggestions stop falling back to hex for tokens
that do not exist — including --moddin-surface, which renders its
hard-coded fallback because no such token is defined. white-space:
pre-line on error paragraphs keeps multi-line validation errors
readable.

Dead code went with it: 10 AI verb keys, 15 error keys, the topbar
Contribute item, which dispatched a window event nothing in the repo
listens to.
The project had no frontend test runner, no clippy, no fmt check and
no pinned Rust toolchain. Four destructive-action confirmations had
landed with nothing asserting they appear.

rust-toolchain.toml pins 1.98.1 with rustfmt and clippy — the version
stable-msvc resolved to, so the build is unchanged and no longer
floats.

fmt and clippy are wired as reported steps, not blocking ones, and the
scale is written down: fmt would rewrite 3,653 lines across 34 files,
and clippy has 144 diagnostics, 53 of them dead_code from the module
implementations that are meant to stay dark. A guard nobody can satisfy
is a guard people disable; the workflow comment says exactly what to
change to make either blocking.

vitest arrives here with the runner config and the npm scripts; the
suite itself lands with the fixes it drove, in the next commit, so
every commit in this series is green on its own.

The runtime bundle no longer rebuilds on every push. The cache key
throws if hashFiles returns empty, there is deliberately no
restore-keys so a job with different inputs cannot fall back to a
nearby bundle, and a verify step fails the job on a truncated cache, a
staged manifest that is not the runtime one, a leaked devDependency, a
missing production dep, a wrong node version, or a failing smoke test.

CI now triggers on every branch prefix CONTRIBUTING.md documents. It
used to run on main, feat/** and refactor/** only, so this fix/ branch
and every hardening/, docs/ and chore/ branch shipped unverified until
someone opened a PR. release.yml reads its notes from
docs/release-notes/ instead of the repo root, where the notes no longer
live, and pins the same toolchain so a tagged build cannot be produced
by a different compiler than CI verified.
…ssed

getMissingDependenciesForModule returned the module the caller had
already asked about as one of its own prerequisites, contradicting its
own doc. configureModule treats a non-empty result as "open the
prerequisite dialog", so clicking Configure on any uninstalled module
always showed a dialog listing the very module being configured and
never reached the install flow.

actionCancel existed in no locale file. ConfirmDialog's own default
label and the capability removal call site both rendered the literal
text "actionCancel" as the cancel button.

ModuleCard disabled Check only on verifyBusy and blockedReason, not
on the install busy flag that disables every other control beside it.
The section's onVerify did guard on busy, so the click was swallowed:
a live-looking button that did nothing and said nothing.

Checked, and left alone: the AI assistant service calls a
build_author_prompt command that does not exist, and the audit called
that a bug. It is a documented decision — the file says the prompt
template iterates faster in TypeScript, and a JavaScript builder is the
fallback so the dialog still works. What is not free is the probe: the
call is made knowing it will fail, so every prompt build costs one
doomed IPC round trip. Cheap, deliberate, and documented; not a
defect to fix under cover of a commit about real bugs.

Every one of the above now has a test that fails without the fix. The
suite is 114 tests over the files where behaviour has broken before:
the unsigned-consent gate, capability card state, the four ConfirmDialog
call sites, History undo, Local AI disconnect, OpenXR system runtime,
and the catalogue helpers. It found two more on the way: Escape closed
a ref no template read, so a keyboard user could not dismiss a
destructive prompt at all — that is the dialog this series added — and
three locales had no actionCancel, so the cancel button rendered the
key name.
The parity guard read four lists. There are five: mcp-server.mjs keeps
its own STEP_KINDS map, feeds it to the agent through get_step_kinds,
and was not covered — and it had already drifted. It was missing two
step kinds and one check kind, and its registry-write entry documented
keyField, nameField, typeField and dataField while the Rust step reads
key, value and type. An agent authoring a recipe through MCP would
have produced a step that passed every kind check and then failed at
install time with "key is required". registry-delete was wrong the same
way.

Kind parity turned out to be necessary but not sufficient, so the
check now also verifies that every param name the map advertises is a
name the runner actually reads. That check was written per-function
first and reported five false positives, because several steps resolve
their path through a shared helper whose literals live in another
function's body. It is file-global instead: weaker, but it cannot cry
wolf, and a guard people ignore protects nothing. Verified by
re-injecting an invented field and watching it fail.

The map is corrected, and extract-zip now documents targetSubdir, which
is the capability that unblocked ofxr-bridge and was invisible to an
agent authoring one.

Also: the topbar Contribute action was removed rather than wired. The
Collections and Contribute product line exists only on an unmerged
branch and is being ported deliberately; a button that opens a dialog
that does not exist is worse than no button.
…d card

F-14 deleted create/list/rollback/delete_snapshot because they had no
frontend caller and the agent could not reach the History panel from
App.vue to give them one. That is the wrong reason to delete a feature.
Snapshots are point-in-time restore points — the only way back from
several undos in a row — with path-traversal-validated ids, and the
History panel has no other way to offer them. Deleting user-facing
capability because the wiring was out of scope is the same defect this
whole pass exists to remove. Restored; the UI follows in the next
commit.

GOG did not deserve the same treatment and did not get it.
refresh_installed_games_async was the only caller of
detect_gog_installed_games_blocking and had no caller itself, so GOG
discovery shipped in the build and was unreachable in the app. The scan
now runs inside detect_installed_games_blocking, which the UI calls on
load.

reshade joins CAPABILITY_BACKED_MODULE_IDS. Its Rust module was
unreachable — no invoke call anywhere — so the library-grid card it
produced fell through every branch of configureModule and threw "no
action": a button advertising an install it could not perform. The
declarative spec is registered and available, so the capability section
is the path that works and the grid card is the one that had to go.
The first rewrite was written before any of this work landed and
described a backlog. This one describes the state after, including the
items the first version did not know about.

The substantive changes are not the checkboxes. Three of them:

  - F-14 gained a rule. The snapshot commands were deleted for having
    no caller and then restored: deleting a user-facing feature
    because the wiring was outside an agent's file ownership trades a
    visible problem for an invisible one. An unreachable command is a
    question — wire it, or say why it is not wanted.

  - O-04 was wrong in a way that mattered. It said feat/agent-mcp was
    "partly cherry-picked". It is not: ~1,500 lines of Collections,
    Contribute and a TransactionsView never reached main. The release
    code on that branch is superseded; the product line renders
    nowhere. The other two branches are genuinely dead — zero commits
    not in main — so the roadmap's guess about the stale [ahead 82]
    annotations was right.

  - The standing constraints gained the lesson of the pass. A guard is
    only as good as its inventory of sources. The parity guard covered
    four of five step-kind lists, and the fifth had already drifted on
    its own — including a registry-write entry whose param names the
    Rust runner does not read, so an agent authoring through MCP would
    have produced a step that failed at install time.

Also new: a section for the five bugs the test suite found, none of
which were on the roadmap, and honest counts for the two CI gates left
reported rather than blocking — 3,653 formatting lines across 34 files
and 144 clippy diagnostics, of which 53 are the dead adapters that are
meant to stay dark.

And the remaining hand-only step: catalog.json still needs re-signing
with MODDIN_SIGNING_KEY, which is not in the environment and must not
be pasted into a chat or a commit.
The snapshot commands were restored in the previous commit with no
caller, which is the dead code this pass exists to remove. The History
panel now creates, lists, rolls back and deletes them.

A snapshot is a restore point, and the two things that make it safe are
both here. Rollback is confirmed, and the confirmation counts the
transactions actually captured rather than assuming one game's worth. A
snapshot that has already been rolled back disables its own button:
rollback_snapshot skips spent transactions and returns an empty list,
so without this the most destructive button in the app is a silent
no-op. The game is an explicit choice with no preselection, because
filing a restore point under the wrong title is worse than making the
user pick.

The panel also fixed a real bug in its host: HistoryView used
EmptyState in the template without importing it, so the transaction
list's empty and busy states never rendered.

create_snapshot was not per-game. The filter compared
record.game_id against a helper that returned record.game_id.clone(),
which is a tautology, and the call site carried the author's own note:
"placeholder for future per-game filtering". So a snapshot of Elden
Ring captured every applied transaction across every installed game,
and rolling it back undid mods in titles the user never named. The
filter is now a pure function over the log, tested directly: three
tests, each paired so none can pass vacuously. Verified by putting the
tautology back and watching them fail.

127 frontend tests, 255 Rust. The suite was checked for non-vacuity by
six mutations — canCreate, ErrorCallout, EmptyState, the @close handler,
the snapshot name and the spent-snapshot button — each caught by
exactly the intended test.
revoked-ids.json was honoured, but it arrived over a connection the app
verified and was trusted anyway: it carried no signature, so an
attacker who intercepted that one GET could suppress a revocation until
the TTL expired. The cache re-verification added earlier could not help,
because the list was trusted precisely when it was stale.

regenerate_catalog.py now inlines the revocations into catalog.json
before the maintainer signs, so one signature covers the capability list
and the revocations and the two halves cannot disagree. The app drops
its second GET, its revocation cache and the revocationsVerified flag;
signatureVerified is the only trust fact left, which is the honest
description of the situation.

The empty case is decided and written down rather than left to whoever
reads it next. A validly signed catalogue with no `revoked` key means
nothing is revoked and the app installs normally. Requiring the key
buys nothing — an attacker who could strip it could rewrite the
catalogue, and the signature refuses either way — while failing on it
would brick every community install against a catalogue generated
before the field existed. A malformed `revoked` value is a broken file
rather than a statement, and refuses the whole catalogue.

The previous "kill switch before signature" ordering is inverted on
purpose and the old rationale is replaced, not quietly dropped. Reading
revocations ahead of the signature meant trusting unsigned bytes, which
is the very defect the inlining removes. What takes its place is
stronger: an attacker who drops a revocation now edits catalog.json,
which breaks the signature and refuses every community install rather
than one capability.

Restores are deliberately untouched. restore_record copies files back
from the transaction's own backups and never reads the catalogue, so a
user who installed a capability before it was revoked can still restore
a snapshot taken while it was installed, and can still uninstall.
Refusing the restore would strand files in a game folder they cannot
remove. It is documented in both the gate and the security file so a
later reader does not "helpfully" add a check to the restore path.

42 tests in the module. The verification key lists became parameters,
because the build ships no private key and there was otherwise no way to
exercise the accept path at all: without it, "revocation is enforced"
could only ever be tested on the side where everything is refused. The
production callers pass the keyring and nothing else, and the fixture
key is asserted absent from it.
Two product lines, both of which the roadmap described as existing and
neither of which did.

Profiles are a versioned `moddin-profile` document. The schema version
is checked *before* the parse, because a future-version file is
well-formed JSON and would otherwise be read as a v1 file with fields
missing — a silent misread is worse than a refusal. The document names
capabilities and the receiving machine's registry still supplies the
recipes, so an import re-runs exactly the checks a card install does.

Applying uses `capability_install`, the same command the capability cards
use, so preflight, transaction and rollback are the existing ones. There
is no second install path. Community capabilities are refused from a
profile outright, because the signature check and the revocation kill
switch live in `community_capability_install` and not in the other one;
the preview says so and points at the Community panel.

The secrets rule is one function, called on both sides, so a hand-edited
file cannot widen it. A field is written only if the capability declares
it, its type is not `path` — the one type whose purpose is naming a
location outside the game folder — and a string value does not itself
look like a local path. Withheld names come back in the file and are
listed in the import preview, rather than being dropped quietly.

Two things the roadmap got wrong. The storage layer is not a profile
store: it is a YAML shape over the catalogue module list with zero
callers. What made this cheap was the transaction log, which is already
the single answer to "is this installed". And there is no file dialog
in this app — no `tauri-plugin-dialog`, no file input, no anchor. The
user picks a name, Moddin owns the folder under `%LOCALAPPDATA%`, and
the file is revealed in Explorer. A real Save-As needs the plugin.

Collections and Contribute were on `feat/agent-mcp` and rendered
nowhere. The branch drove a backend session with its own install,
resume and abort commands — 1,127 lines that would have been a second
install path. The frontend is a loop over the existing
`community_capability_install` instead, with the same behaviour: ordered
steps, stop and ask on failure, revert newest-first, every step
transaction-recorded. The dialogs collapsed into `BaseDialog` and
`ConfirmDialog`, errors into `useFriendlyError`, empty states into
`EmptyState`, and the window-event bus is gone.

Two files from the branch were deliberately not ported. The branch's
TransactionsView is a pre-History rendering of data the History panel
now shows with per-record labels, file counts, filters and a confirmed
undo; porting it would ship a second, worse transactions UI. The
branch's LibraryEmptyState has nothing left to own.

The topbar Contribute button is not restored. It lived in files this
work does not touch, it is unnecessary now that the sidebar entry works,
and the button existed only to reach a dialog that did not — which is
how it became a dead button in the first place.

59 collection keys and the profile keys, all three locales, all
hand-written rather than machine-translated. 195 frontend tests, 283
Rust. The two capability test fixtures gained the engine fields the
type now requires; they claim no engine, because they are about check
rendering and would otherwise need their own engine reasoning.
…logue

supportedEngines was parsed on every spec and then ignored, so a game
declaring RE Engine was offered UEVR.

The gate is in Rust, not the frontend, because it is the only layer
that holds the spec while the list is being built — capability_list
returned summaries, so a filter invented on the TypeScript side would
have had to re-fetch every spec to make a decision it could not
otherwise make. A game whose enginePreset no recipe names gets
everything, which is what doom-2016 needs: nothing Moddin ships
targets idtech, so a strict gate there would empty the page.

The empty-supportedEngines case was decided on evidence rather than
preference. An empty list means every engine, and that is what the
repository's own template does — the one shipped community recipe
declares none. The opposite reading would have made that recipe
uninstallable everywhere, which is the worse failure to discover in
production.

Two cases are load-bearing and neither is hypothetical. elden-ring
declares no enginePreset at all, and it is the flagship VR title;
gating on an engine nobody declared would hide UEVR from the game
people most use UEVR with. dawnwalker offers a REDengine UEVR recipe
that the same gate would hide — a data conflict the guard now reports
rather than resolving, because resolving it is a product call.

validate-catalog.mjs checks the class the audit said was unchecked: a
wrong executable, an enginePreset no preset defines, a module whose id
nothing implements, a game pointing at another game's directory, and a
game offering a module the engine preset's own description excludes.
That last one is the dead-island-2 shape, and it is how the preset
denial is read rather than matched by hand.

It found eight real defects in the catalogue as it stands. They are
reported, not suppressed, and each is baselined by name so the class
fails the build anywhere else while the existing instance stays
visible. A baseline is a fourth place a defect can be named, and a
reviewer should read every line in it as a bug rather than as tuning.

One is fixed here: cyberpunk-2077 offers uevr as `available` while the
redengine preset's description says Moddin bundles no UEVR-style
injector for it and the recipe is unreal5-only — an advertised
one-click install that the new gate removes. Marked planned, which is
what the rest of the data already said. The rest are declared intent
(planned modules that render as planned) or product calls, and are
left for a human.

The community validator now refuses an engine id the runner has never
heard of, because such a recipe can never match a game and is invisible
on all of them — it fails the author instead. That makes the engine
vocabulary load-bearing in that repo, and therefore a fifth list that
has to be compared rather than a sixth left to drift: the parity guard
now covers it, and was verified by removing an id and watching the
guard name the file.

Four lists agree on step kinds, check kinds, engine ids and engine
verdicts. Every one of the four was found by a human auditing the
source.
csp was null while the app ships a 67.8 MB node.exe and runs an MCP
agent. The policy now covers the production build, and it was verified
rather than assumed: the app was launched and the header read out of
WebView2 over CDP, with zero console entries on a full reload. The one
inline script the frontend has is the boot-error hook in index.html,
and tauri-codegen hashes it into script-src at build time — so it keeps
working without weakening the directive to unsafe-inline.

The interesting finding is what is *not* in the policy. The MCP agent
is not loaded into the webview: ai_assistant_setup.rs resolves node.exe
and the agent script and writes them into an external tool's config,
which launches them over stdio. No register_*_protocol call exists in
the codebase, so node:// and a custom protocol entry would have been
decoration. The frontend makes no fetch at all — every GitHub,
catalogue and UEVR request is Rust-side reqwest, which CSP does not
govern — so connect-src is just ipc: and the Windows http://ipc.localhost
form Tauri v2 needs.

devCsp is a correct declaration that is currently not enforced: in
tauri 2.11.5 the manager consults the policy only on asset-protocol
responses and data: URLs, never on the Vite dev page. It is there so
that if a future Tauri applies it, the HMR socket and inline styles
have a policy that expects them.

The size claim in the roadmap was pointing at the wrong file. The
28.5 MB node.zip is not in the installer — bundle.resources is an
explicit list, and extracting the built installer with 7-Zip finds
zero zip entries. What *was* shipping is 56 MB: the old script copied
moddin-agent/node_modules, the full dev install, into the staging
directory, renamed it node_modules.old, and never deleted the rename.
6,296 entries, 56,097,177 bytes, in every build.

  installer        31,705,095 → 24,403,351   −23.0%
  unpacked payload 160,073,106 → 103,975,929

[profile.release] gains thin lto, codegen-units = 1 and strip. Thin and
not fat because [lib] is a staticlib/cdylib/rlib crate, and fat LTO
across a multi-linkage crate is where the relocation failures live. The
executable drops 6.7% and still launches and renders.

The bundler now installs with npm ci against the lockfile that already
exists and is already tracked — moddin-agent/package-lock.json — rather
than resolving ranges at bundle time. The staged manifest pins exact
versions read out of that lock with the bundled node. Two consecutive
ci runs produce an identical node_modules: 3,869 files, same tree hash.
Pinning the manifest alone would not have been enough; it covers four
direct dependencies and leaves ninety-one transitives to the registry.

Two bugs found on the way. The script would have thrown on Windows
PowerShell 5.1 — ConvertFrom-Json rejects the lockfile's packages[""]
key with "the argument name is not valid" — and CI runs pwsh 7, so it
would have shipped green and broken for every maintainer on 5.1. And
the old cleanup of the staging leftovers was best-effort behind a
comment normalising failure, so it had been failing silently for long
enough to hide 56 MB; it now throws.

Known costs, stated rather than discovered later: strip removes the
shipped symbol table, so a user-reported crash yields no symbols; npm
ci fails where npm install would have limped, which is the point but is
a new hard failure mode if the agent lockfile ever drifts from its
manifest; reproducibility is verified on npm 11 and CI runs npm 10, so
that combination is unverified. Deleting a locked node.exe now fails the
bundle rather than shipping a truncated one.
The first two rewrites described a backlog. This one describes the
state after, and the substance is in what it had to correct rather than
in the checkboxes.

F-03 is fully closed. The revocations now travel inside the signed
catalog.json, so one signature covers both halves and they cannot
disagree. Before, revoked-ids.json was honoured over a connection the
app verified and trusted anyway, and an intercepted GET could suppress a
revocation until the TTL expired. The old "kill switch before
signature" ordering is inverted on purpose and its rationale replaced
rather than dropped: reading revocations ahead of the signature meant
trusting unsigned bytes, which is the defect the inlining removes.

Four Later items closed: engine gating, catalogue validation, profile
import/export, and CSP plus a 23% smaller installer. The size claim was
pointing at the wrong file — the 28.5 MB zip is not in the installer,
and what was shipping is 56 MB of a dev node_modules that a
best-effort cleanup had been failing to delete silently.

Two roadmap premises were wrong and are now recorded as wrong. O-04
said feat/agent-mcp was "partly cherry-picked"; it holds about 1,500
lines that never reached main. And the profile item called the storage
layer a profile store; it is a YAML shape over the catalogue module
list with zero callers, and there is no file dialog in the app at all.

create_snapshot is recorded as what it was: not per-game. A snapshot of
Elden Ring captured every applied transaction across every installed
game, under a placeholder note the original author left, and rolling it
back undid mods in titles the user never named.

The standing constraints gained the lesson of all three passes, which
is that a guard here is usually right about the class and wrong about
the inventory. The step-kind guard compared four of five lists and the
fifth had already drifted. The engine vocabulary now lives in five
places and the guard compares five. A baseline in a validator is a
sixth place a defect can be named.

The one remaining Now item needs a key, not a change: catalog.json must
be regenerated and signed once, with the final format.
…em fail

Both gates ran in CI and reported. Neither blocked, because neither
was satisfiable: fmt wanted to rewrite 3,653 lines across 34 files, and
clippy reported 144 diagnostics across 30 lints. The CI count was raw
emissions including duplicates across the lib, lib-test and example
targets; deduplicated it was 81 unique diagnostics, 23 lints, 34 of
them dead_code. The tree is now zero and both gates block.

The fmt diff was not accepted blind. No #[rustfmt::skip] exists
anywhere in src-tauri, so nothing could be silently skipped; string
literal values are unchanged in all 31 files, checked with a literal
scanner rather than by eye; and the only residual non-whitespace
changes are rustfmt's own four transforms — line breaks, trailing
commas, reorder_imports, and brace elision. Brace elision is the one
that could change meaning, so every elided body was checked for a `;`,
`?`, `return` or `break`.

Six files deleted, each confirmed unreferenced by a repo-wide search
before deletion rather than on the strength of the document that
described them: the five *_module.rs adapters, and updater.rs, which
has never had a mod declaration in any commit and so has never been
compiled. The cascade is accounted for rather than tolerated — the
Module trait's satellites in module.rs and the digest layer in
archive.rs were reachable only from the adapters.

That cascade found three real things, which is the point of doing this
in order rather than allowing the lints away.

  - archive.rs's SHA-256 layer could never verify anything.
    RemoteArchive::with_expected_sha256 was never called, so the
    expected digest was always None and fetch_verified() was fetch()
    with a branch that could not be taken. The verification that does
    happen is inline in builtin_checks.rs and in the verify-hash step,
    and the module doc now says so instead of implying otherwise.
  - StepContext::spec was never read by any of the eight step kinds.
  - ai_assistant_setup::restore_from_backup was never called, so setup
    writes a .moddin-bak that nothing ever restores. That is a real gap
    in the config-write safety net, and it is left as a flagged item
    rather than fixed here: wiring it is a behaviour change, not a lint.

too_many_arguments got the real fix, not an allow. install_directory
and executable_directory were adjacent same-typed &Path parameters
across five functions, and swapping them compiles silently and points
the backup scope at the wrong root. They are one InstallScope now, and
each signature is at or under five parameters. Sixteen call sites
updated.

Four #[allow]s remain, every one item-scoped and every one carrying a
reason: the un-adopted Module contract in module.rs, kept as the only
executable specification of an interface whose other half is a YAML
pipeline. Two scaffolds that existed only to keep an import alive are
gone. No [lints] block was added, because the tree is clean without
one and a blanket allow would turn the gate into a switched-off guard —
which is what a reported-only gate already was.

Deleting ofxr_module.rs removed the crate's only "ofxr-framegen"
string, and validate-catalog.mjs builds its module inventory by
scanning for exactly that pattern. The card it backed was therefore
never backed by anything that dispatched: five catalogues advertised a
button whose action did nothing. The module is now `ofxr-bridge` in all
five, with its dead config block dropped, and it joins the
capability-backed list in App.vue — the same migration reshade needed
earlier today. No Rust shim was added to satisfy the check; that would
be creating dead code to turn a number green.

270 Rust tests pass.
…pass

UX-21 mapped the four backend-authored identifiers to locale keys at
the service layer, so a component no longer sees a backend id. The
capability-card description that was English in every locale is the
thing it actually fixes.

The decision that mattered is the one about ids the table does not
declare: they render the backend's own text, verbatim — the command
name for an action, the recipe's own sentence for a check, the warning
sentence, the real I/O message for an agent detail. Not blank, not
refused. A log row with no action is untriageable and a warning that
omits the missing manifest removes the one actionable fact.

The enforcement is the other half, and it is why the fallback is
reachable only by a string the app does not know about: every table is
checked by set equality against the source the ids come from — the
persistent-action list, the literal `warnings.push` strings in
openxr.rs, the `Some("…")` values in `inspect_agent`, and the recipe
YAMLs for capability and check ids. Sources are read through
import.meta.glob rather than node:fs, so a renamed recipe becomes a
build failure instead of a stale entry. Verified by deleting one
warning label and watching the test name the missing key.

`check.label` is keyed by check id, not by label, because the shipped
recipes write the same id with different labels per capability
("BepInEx archive reachable" versus "OFXR archive reachable"). A
per-id table therefore holds a capability-neutral label and the card
header supplies the product.

UX-26 through UX-29 are done: per-item aria-labels on every repeated
icon-only action, built from the visible text so label-in-name holds
for voice control; sr-only labels on the activity-log controls;
aria-required and aria-invalid on config fields, wired to the real
state rather than to first paint; and aria-busy on six regions. The
ToastStack was announcing every message twice — a polite container
plus alert children — and the container is now silent.

UX-25 and UX-06 are closed as written, with the reasoning recorded
rather than worked around.

UX-25's subject no longer exists. There is no role="menu" anywhere in
the tree: Tier 2 collapsed the five AI verbs into one button and took
the menu with it. Inventing a menu to give it a keyboard model would
be building the opposite of what Tier 2 did.

UX-06's premise is stale. The three call sites do not share a
spec-driven config form: the game page is the only one that has one,
and it is inline in the card rather than a dialog; the Community panel
installs with an empty config and has no form; the AI flow shows the
field names read-only beside a YAML editor. Extracting a shared dialog
would have had one caller. What it exposed instead is a real gap: the
Community panel cannot install a recipe that declares required config
fields because it never collects them, and that needs field labels
first.

Two more scope notes, both honest rather than hidden. A community or
AI-authored recipe keeps its own English — it is the author's copy and
the app does not rewrite what a person wrote. And check.detail
("1.2.3 is outside the supported range") is data with an embedded
version, not prose to translate.

The new service test covers the boundary's pure functions rather than
the Tauri calls around them: the architecture guard allows exactly one
invokeDebug caller per feature and has no test exemption, so reaching
the bridge would have meant working around the guard rather than
through it.

238 frontend tests, up from 195. 49 new locale keys in each of the
three languages, insert-only — a key written and found unused was
removed rather than left as dead payload.
The Tier 3 work landed with two of its three items closed as written,
and the roadmap said neither was possible. Recording that here rather
than letting the next person re-derive it.

UX-25's subject no longer exists. There is no role="menu" in the tree:
Tier 2 collapsed the five AI verbs into one button and took the menu
with it. Building a menu in order to give it a keyboard model would
have been undoing Tier 2.

UX-06's premise was stale. The three call sites do not share a
spec-driven config form. The game page is the only one that has one,
and it is inline in the card rather than a dialog; the Community panel
installs with an empty config; the AI flow shows field names read-only
beside a YAML editor. Extracting a shared dialog would have had one
caller, which is the definition of a refactor that adds an indirection
without removing duplication.

It did find something real on the way, now a Later item: the Community
panel cannot install a recipe that declares required config fields,
because it never collects them. That is a missing feature, not a
refactor.

Also closed: the fmt and clippy gates, with the corrected count. The
"144 across 30 lints" in CI counted raw emissions including duplicates
across three targets; deduplicated it was 81, and the tree is now zero
with both gates blocking. And the ofxr-framegen migration, which is
recorded as a consequence of the adapter deletion rather than as a
planned item — five catalogues were advertising a card whose only
backing was a file nothing dispatched to.

Two new Later items that came out of the UX work rather than out of
any audit: translating safetyNotes, configSchema descriptions and
displayName, which the boundary deliberately does not cover, and the
Community panel's missing config form.

Plus the localization boundary's own caveat, written into the item
rather than left in a worker's report: a pt-BR capability card still
reads English in those fields, and a community or AI-authored recipe
keeps its own English because it is the author's copy.
The one remaining Later item, and the only one with a named risk: the
audit records that the last time this was needed, users downloaded a
knowingly broken installer, because the signing key was a Rust constant
and rotation meant shipping a binary.

Three commands and a panel. `app_update_status` reports this build's
version, the channel, and whether the pubkey is real.
`app_update_check` makes exactly one request, and only because the user
clicked — no startup check, no timer. `app_update_install` re-checks,
and refuses if the release moved from the version the user confirmed,
which is the window between "3.2.0 is available" and "install".

The refusal is the deliverable. Three things stop the placeholder from
behaving like a key, and the first is the important one:

  const _: () = assert!(...)  under #[cfg(not(debug_assertions))]

A release build with the placeholder still in place fails to compile
with a message that names the command to run and the document to read.
Verified by running it, not asserted: cargo check --release --lib gives
E0080 with that text. It is gated on not(debug_assertions) on purpose —
an always-on version would make the two blocking gates permanently red,
and a permanently red guard is a guard that gets deleted. Beyond that,
both commands return not-configured before any network call, and there
is no fallback arm anywhere in the error mapping.

The error mapping is keyed on the plugin's error *variants*, not their
strings, so upstream rewording cannot reclassify a signature failure
into something that looks recoverable. Minisign, SignatureUtf8 and
Base64 all map to a refusal.

A failed update leaves a working app by construction. Every failure
returns Err and installs nothing; a signature mismatch has no fallback
path; a failed installer launch returns before the plugin touches any
file. On Windows the process exits only on success — the NSIS installer
is launched with /P /UPDATE /R /ARGS and restarts Moddin — so there is
no relaunch command and no tauri-plugin-process dependency. That is a
deviation from the brief and it is deliberate: a relaunch of our own is
unreachable on the only platform this ships.

The capability file grants `permissions: []` rather than
`updater:default`, because the default would let the webview install
without confirmation — and it is .json because the .yaml files beside it
are capability recipes that validate-catalog.mjs globs.

Known and stated rather than discovered later: a valid-but-wrong pubkey
is undetectable at build time, so the dry-run tag procedure is the
guard. There is no build check against reusing the community keyring,
and deliberately so — those are private consts in community_catalog.rs,
and duplicating them would create exactly the drifting list this repo
keeps getting bitten by.

Cost: reqwest 0.13 alongside 0.12, zip 4.6.1 alongside 2.4.2, a second
windows-sys, plus minisign-verify, tar and flate2, and two transitive
bumps the plugin forced. tauri 2.11.5 and the pinned toolchain are
unchanged. Binary size and build time grow.

281 Rust tests, 248 frontend. docs/UPDATER.md carries the four things
the maintainer substitutes and where.
The updater was wired with three refusals, and this is the fourth: the
release job now throws when the payload is incomplete.

tauri build produces the versioned NSIS zip whenever the updater plugin
is configured, and the .sig only when TAURI_SIGNING_PRIVATE_KEY is
present. So a release run without the secrets yields a zip with no
signature beside it — an updater payload the app would refuse on
signature mismatch, on a release that otherwise looks fine. The job
stops instead, and the error names the document rather than uploading
something that cannot be verified.

That is the failure the audit records from the last time a release went
out wrong, one layer up: the installer shipped and the digest beside it
was typed rather than computed. The two guards are independent on
purpose. The desktop build already refuses to compile while
tauri.conf.json holds the pubkey placeholder, so a release cannot
reach this step with an unconfigured key anyway; this one stops a
signed-but-unpublished payload, and a zip with a missing latest.json,
which Tauri writes only when the secrets are present.

I wrote the step to skip gracefully first, on the reasoning that a
release before the updater is configured should still produce an
installer. That reasoning was wrong, and the comment said so while the
code did the opposite — the skip branch would have published a release
whose update check could never succeed. Failing loudly is the correct
behaviour and the comment now matches it.

The release attaches latest.json, the zip and its .sig only when all
three are present, so a partial artifact set cannot be published under
the same release.

Ordering note, left as it is: the updater steps run between the build
and the installer's own hash-and-upload. That reads oddly and is
correct — the two artifacts are independent, and the installer's digest
still comes from the file it hashed.

docs/UPDATER.md carries the two repository secrets and the one config
line the maintainer substitutes.
110,278 → 73,213 bytes on disk, 107,739 → 71,488 as the guard measures
it. Headroom against the 115,000 budget went from about 7 KB to about
43.5 KB, with the budget untouched — raising it would have been the
other decision, and it is not this one.

The seams were found before the sizes. The per-module action table —
the `if (module.id === ...)` chain running through openModulePreview,
removeModule, checkModuleUpdate and the verify branches — became
`features/modules/module-registry.ts` and is now the contract rather
than a control-flow chain. Four other concepts followed: how a module's
preview becomes a pass/fail checklist, what one card shows, the
verification scheduling with its generation guard and TTLs, and the
compatibility-report vocabulary and storage.

What stayed, deliberately: the six dialog refs and their template, the
apply actions, because each one post-processes its result differently —
OFXR's `armed`, UEVR's metadata version, OptiScaler's extra inspect —
and game selection. The five preview dialogs also stayed. They share no
state and no command, and one component hosting five unrelated dialogs
is the same mistake at a smaller scale.

The module-handler test is the part worth the change. Twenty-three
cases hold the table against the three things it claims to mirror: every
available non-capability-backed module in the catalogue must have a
row; every command the table names must be registered in the backend
invoke_handler list; and every one must actually be invoked by some
feature service, so the table and the code that runs cannot each be
right about a different command. It also encodes the distinction the
list exists for — the five capability-backed ids must have no grid row
and no transaction kind, and each must have a recipe the binary embeds,
which is exactly what reshade.rs and ofxr_module.rs violated.

Proven rather than asserted. Dropping `ofxr-bridge` from the
capability-backed list fails with all five games that would have
rendered a dead card. Renaming the ofxr-framegen row fails three ways.
While writing it, the test caught the case that made the assertion on
`available` rather than on every id: kharvox-vr and
cheeky-foveated-dlss-uevr are planned, so their cards are disabled
"Planned" cards — the only reason they are not dead buttons. Flip either
to available and the test fails immediately.

Four behaviour bugs found and left alone, which is the correct call for
a refactor whose whole value is being reviewable:

  - moduleProgress counts capability-backed modules the grid never
    verifies, so for Stalker 2 the hero bar cannot reach its total.
  - UEVR never self-verifies, so it is the only available card sitting
    on "unknown" until the player presses the button. The original
    reason was not documented, so the skip is documented rather than a
    guess invented.
  - checkModuleUpdate writes an English detail string with no locale
    key, which is also unobservable because the card hides the update
    area when there is no source.
  - `updateModule` sets a `configured` flag that is never read.

321 frontend tests, up from 248. The new tests need three
`@ts-ignore` lines to reach node:fs, because tsconfig carries only
vite/client types; `@ts-ignore` rather than `@ts-expect-error`, so they
will not start failing if node types arrive later.
O-10 is done and the number is the one that mattered least. The App.vue
decomposition produced 110,278 -> 73,213 bytes against an untouched
115,000 budget, but the reason to keep it is the module-registry test
that came with it: 23 cases holding the per-module action table against
the catalogue, the backend's invoke_handler, and the services that
actually invoke each command.

Two module ids have already been dead cards in this repo's history,
reshade and ofxr-framegen, and both were caught by a catalogue
validator rather than by a guard. That test is what makes the next one a
build failure instead of a support ticket, and it is worth more than
the 37 KB.

F-04 moves to closed-as-written: the updater is wired and refuses to
install anything while the signing key is a placeholder, verified by
running the release build rather than asserting it. What is left is the
maintainer's, in docs/UPDATER.md — the pubkey, and two repository
secrets.

The updater entry also records the mistake in the release workflow,
because it is the kind that reads as caution and behaves as a hole: the
step was written to skip gracefully when the updater payload was
absent, on the reasoning that a release before the updater is configured
should still produce an installer. The skip branch would have published
a release whose update check could never succeed. It throws now, and
the comment matches the code instead of the other way round.

45 done, 3 closed as written, 11 open. The eleven are product calls,
not engineering: collections content, a second signed community
capability, UE4SS and REFramework recipes, per-game flat profiles,
Nexus, the Community panel's missing config form, translating
safetyNotes and configSchema descriptions, and the eight baselined
catalogue defects.
A refactor whose value is being reviewable must not smuggle behaviour
changes into itself. These were found and left; now they are fixed.

The hero progress bar had the wrong denominator. summariseModuleProgress
took a bag of already-computed states, so the decision about which
modules count lived in App.vue, which passed every available module
including ofxr-bridge — a module the grid excludes because the
capability section renders it, and whose card state is permanently
unknown because no grid verification ever runs. Stalker 2 had a total
of 5 behind 4 cards, so the bar could never finish. The function now
takes `(modules, stateOf)` and the denominator decision lives in the
file that already owns the card decisions, filtered to what the grid
actually renders.

UEVR never self-verified, and the skip bought nothing. preview_uevr is
the only preview that reaches the network — it resolves the release to
compare the installed version — and checkAvailableModuleUpdates runs on
the same selection in the same background timer, against the same
GitHub endpoint, because UEVR's recipe declares releaseApiUrl. So the
verification never prevented a request; it only left the card blank.
The filter is removed and the vague comment replaced with the actual
reason. A test asserting "skips a module whose card is hidden until
its engine gate passes" was deleted rather than kept: its name claimed
a behaviour the code never had, since selectModuleGroups hides
capability-backed modules and nothing to do with engines.

The untranslated update string is gone rather than translated.
moduleHasUpdateSource is the same predicate checkModuleUpdate used, so
when there is no updateUrl the card hides the whole update area and the
stored `unavailable` record had no reader — and updateSummary already
maps that status to a key that exists in all three locales. A fourth
key for a string nothing renders would be dead payload.

updateModule does not exist. It has never existed as an identifier in
any commit; the ROADMAP's claim came from a report that read a
near-match. The real set-but-never-read `configured` in that path was
the dead `unavailable` record the previous fix removes, so "prefer
removing" is satisfied there. The one genuinely unused `configured` is
in the updater's own types, and it is used now rather than dropped:
the build knows its key is a placeholder and says so before any network
call, so the panel settles into `not-configured` at load instead of
offering a Check button whose only possible outcome is a refusal the
user has to trigger to learn about.

write_config now owns its own backup in ai_assistant_setup. Three
callers each took a `.moddin-bak` copy and then called a write_config
that did a bare fs::write, so a failed write left a truncated mcp.json
and the backup was never read; the two atomic_write sites took no
backup at all. Only write_config holds both the old bytes and the
destructive call, so it takes the backup itself and restores on
failure, composing both failures into the message. The three
redundant caller-side copies are gone, and the guard tests is_file
rather than exists so a directory in the config's place reports
"could not write" instead of a confusing "could not back up".
Note that restore_from_backup was deleted in the dead-code pass rather
than left uncalled; this reinstates it in the only place that has both
inputs.

The catalogue validator's baseline wording said a planned module's
"primary action reports that it has no action", which is false — a
planned module renders a disabled Planned card, because
moduleActionLabel returns statePlanned with actionDisabled. The three
affected entries now say what renders, and the check's own message is
status-aware so the next reader is not sent hunting for a dead button.
Proven by adding a planned module to a game and watching it exit 1.

326 frontend tests, 297 Rust. module-registry.test.ts is untouched and
green: none of this changes the per-module action table.
…catalogue

The Collections line was ported from the abandoned branch a day ago and
rendered an empty state, because the branch shipped the loader with
zero built-in collection YAML. The loader is now here and there is
content for it.

`collection_list` is the only command registered. collection_install,
collection_resume, collection_abort and collection_save_yaml belong to
the session runner that was deliberately left behind, and registering
them would recreate dead code this pass spent a day deleting. There is
no collection_reload either: the registry is rebuilt per call, like
capability_list, so a dropped local YAML shows up on the next open.

The port needed real adaptation. The branch's capabilityMatches was a
name-only existence check, and it is now validation against the
CapabilityRegistry — that is the reason to port the loader at all, and
it means a collection naming a capability that does not exist, a
`planned` one, or one whose engines do not fit is refused at load time
rather than discovered half way through an install. Engine handling
goes through the same EngineScope and CapabilitySpec::engine_match that
F-09 added, so a collection and a capability card cannot disagree about
whether something fits. The per-caller eprintln for every skipped file
became a CollectionLoadReport, because "skipped, not fatal" was not
testable and the file name is now in an assertable value.

Two collections, two games, two engines: vr-cyberpunk-2077-stack
(redengine) and vr-stalker-2-stack (unreal5), each optiscaler then
ofxr-bridge, upscaler first with frame generation on top. Every member
was checked against the shipped recipe, the game's enginePreset read
out of src/catalog/games, and the game's own catalogue entry — and
`every_built_in_collection_fits_the_game_it_targets` reads those files
and fails if a member stops fitting.

Members excluded on purpose, with the reason recorded in each YAML:
uevr and ue4ss and reframework are planned and refuse to install by
design; cheeky-foveated-dlss is a ReShade add-on whose dxgi.dll proxy
collides with OptiScaler's, and a collection cannot pin a proxy;
openxr-helpers needs absolute per-machine paths; bepinex is unity only.
The branch's own two collections shipped uevr, which is exactly the
mistake this loader now refuses.

Three catalogue files carried a UTF-8 BOM, which serde_yaml rejects
outright — "YAML containing more than one document". dawnwalker,
dead-island-2 and stalker-2. Nothing parses them from Rust today, so
this was latent; it would have been the first thing anyone extending
the catalogue hit. BOMs removed, CRLF preserved, all six still parse.

The dead installCollection stub that threw an English string is gone;
nothing called it.

297 Rust tests, 337 frontend. Nothing signed, and no git state changed.
…elpers pretending

`registry-write` built `reg add <key> /v <value> /t <type>` and dropped the
payload on the floor. A REG_SZ wrote an empty string; a REG_DWORD wrote
nothing at all and failed on reg.exe's own `invalid syntax`. The step now
takes `data` (literal) or `dataField` (a config field, so a `number` field
can carry a DWORD), renders `{configField}` in `key`, `value` and `data`
the way `write-text-file` renders its body, and passes `/d` only when
there is a payload. A numeric type with no payload is refused with a Moddin
message instead of reg.exe's.

`registry-delete` is now idempotent. reg.exe exits 1 both for "nothing to
delete" and for a real failure, and the message that tells them apart is
localised — this machine answers in pt-BR — so the step asks `reg query`
whether the target is still there. The extra process only runs on the
failure path.

openxr-helpers is now `planned` with an empty chain. The previous revision
wrote `HKCU\...\OpenXR\1\per-game\{gameExecutable}\ActiveRuntime` and
described that as binding a runtime for one game. The OpenXR loader
specification puts `ActiveRuntime` under one machine-level key and says
outright that runtime selection is handled external to the loader; there
is no per-game registry key in it. The recipe was creating a key no loader
reads. Closing it is a runner gap — a step that expands environment
variables, and JSON escaping for a Windows path — not another step kind,
and the spec says so rather than shipping the claim.

Also corrects the step-kind count in MODULES.md (13, not 14; the list had
been missing `git-checkout` and `build-project`) and the OpenXR row, which
still described the recipe as writing something.
…-picked one

Two invariants that were written against `openxr-helpers` and nothing else
now run over every built-in recipe, so adding a tenth YAML file is covered
without remembering to extend a test.

- a step may not reference a config field its recipe does not declare; an
  undeclared placeholder renders to nothing and gets written as itself.
- a check may not name an absolute path, and may only inspect something
  its own recipe's chain produces.

Writing the second one cost three false positives before it was right, and
the reasons are the point of the commit:

`ofxr-bridge` was reported as declaring `sha256` for nothing, while its
`archive-sha256` check reads exactly that field through `expectedField` —
the first version scanned steps and not checks. `bepinex` was reported as
checking a file no step writes, when `extract-zip` is what puts
`BepInEx/core/BepInEx.Preloader.dll` there; an extracted archive is the one
artifact whose whole tree is the product, so a check inside it is verifying
the download. And the sibling rule, "every declared field is consumed", is
deliberately not asserted: `configSchema` is the union of three consumers
in three languages — the chain, `src/features/modules/module-registry.ts`
for the typed module commands (`proxyCandidates`, `addonFile`), and
`build_metadata`, which records `version` on the transaction. A guard that
named all six available recipes as broken on day one would train everyone
to skip it.
UE4SS was `planned` with no chain, on the stated grounds that its
engine-generation matrix "has to be pinned first". That premise was wrong:
v3.0.1 ships one runtime archive, not a per-Unreal-version split. The
release archive was downloaded and listed rather than trusted from docs —
46 entries, rooted, with dwmapi.dll, UE4SS.dll and UE4SS-settings.ini at
the top level — and upstream's own install guide is "drop the archive into
the game executable directory", which is `download-file` then `extract-zip`.
It is `available` now, with the seven safety notes each citing what they
rest on. No engine-version range is asserted, because none was verified.

REFramework stays `planned`, and the reason is now specific instead of
vague. Its layout is verified — v1.5.9.1's RE8.zip is rooted, dinput8.dll
at the top level — but the release note says non-VR users must extract only
dinput8.dll and that extracting the rest may crash the game, and
`extract-zip` writes every member with no include or exclude param and no
conditional step. Staging plus `move-file` installs the flat-screen case
correctly and silently gives a VR user no OpenXR support and no autorun
scripts, which is worse than refusing. Closing it needs a member filter on
`extract-zip`: a runner change, named as such in the spec.

Adds `proxy-present` and `core-present` to the UX-21 check tables and to
all three locales. The i18n guard caught the omission — every id a shipped
recipe names has to be declared — and the labels are written by hand in
en, pt-BR and es.
Two install paths were dead. A collection member always went out through
`community_capability_install`, and the Community panel sent `config: {}` —
which did not just fail preflight, it failed to deserialise, because
`ResolvedConfig` has a required `values` map. Neither could install
anything that wanted configuration.

The catalogue now answers. `src/services/catalog.ts` reads a game's
`config:` block verbatim (`findCatalogModule`, `resolveCatalogConfig`), and
the new `src/features/capability-modules/config.ts` is the schema-aware
half all three callers share: parse the spec's `configSchema`, seed values
in one precedence order (user, then catalogue, then the recipe's own
default, then a type blank), and report what is still missing. One rule,
one place, no constant table.

Collections route each member by asking the registry once per run: a member
it knows goes through `capability_install` with the resolved config,
anything else through `community_capability_install` with the same config
and `acceptUnsigned: false` still set. Every member is fetched and planned
before the first install, so a missing field is reported before anything is
written instead of halfway through.

A community member in a collection is refused at load time, in
`collection.rs`, next to the planned and engine-mismatch refusals. Its
signature can only be checked at install time and the run installs with
`acceptUnsigned: false`, so carrying one would fail on member 2 with member
1 already on disk. The wording names the reason rather than the generic
"not a registered capability", so an author does not go hunting for a typo
in an id that is correct.

A required field with no value is never made optional and never given an
invented default: the panel keeps the form, marks the fields invalid and
names them, and a collection reports `errorCode: needsConfig` with the
per-capability field list, without calling install.
…assing for the wrong reason

A collection run has no per-member form, so a required field with no value
has to be reported by the dialog itself. It was not: the error computed
fell through to `install.error`, which is null for the `needsConfig` code,
and the user got a generic "something went wrong" heading with no body —
told that something failed, with nothing to act on. It now names the member
and the fields, and says nothing was installed, which is what makes the
refusal safe to retry. Written by hand in en, pt-BR and es.

The test next to it was asserting the community install command for `uevr`
and `vd`, and passing — because the mock had no `listCapabilities`. The
composable caught that, cleared its registry of built-in ids, and sent both
members down the community path: the assertion held on a branch the mock
had forced, not the one a real build takes, which is the exact failure mode
a test is supposed to catch. It now mocks the whole service surface, both
members are built-in, and it asserts `capability_install` — and that
`installCommunityCapability` is never called.

Correcting it also corrected the assertion itself. The built-in call
carries no `acceptUnsigned`, and should not: that flag is how the community
command says "this recipe is not in the registry", so sending it for a
recipe the registry knows is a category error rather than a safety belt.
The test now says so explicitly.

The refusal test is red without the dialog change and green with it.
Both shipped collections refused to install: a collection resolves each
member's required fields from the game's `config:` block, and a required
field with no value refuses the whole run before anything is written.
`ofxr-bridge` had no `config:` block in any game at all (nine of nine
fields missing) and `optiscaler` had no `proxy`. Two shipped collections,
zero installs.

OptiScaler is now complete for both games. Every value was verified rather
than recalled, and the digest was **computed from the downloaded asset**,
not copied: v0.9.4 is a single 55,016,448-byte `.7z`, and its SHA-256
matches both GitHub's own `digest` field and the value already committed
for cyberpunk-2077. `proxy: dxgi.dll` is not a guess either — it is the
default named by the asset's own `setup_windows.bat`.

OFXR-Bridge is now five of nine, and the remaining four are **deliberately
absent**. They are all `type: path` and all resolve under
`%LOCALAPPDATA%`, which no step can reach: `render_template` substitutes
only literal `{name}` and `resolve_path` treats anything non-absolute as
game-executable-relative. A committed value would have to be a fabricated
`C:\Users\<someone>\…` that breaks for every other user, or an
exe-relative path that drops the tray and the OpenXR layer manifest inside
the game folder — contradicting the recipe's own safety notes and not
where the runtime looks. The field stays absent and the collection keeps
refusing, which is the honest outcome.

**So: this commit does not make the collections install, and the table
that says so is the point.**

| collection | member | outcome |
|---|---|---|
| vr-cyberpunk-2077-stack | `optiscaler` | all required fields present |
| | `ofxr-bridge` | refuses: `runtimePath`, `trayExe`, `trayIniPath`, `extractedDir` |
| vr-stalker-2-stack | `optiscaler` | all required fields present |
| | `ofxr-bridge` | refuses: same four |

Two further blockers are in the recipes, not the catalogue, and are left
for the recipes to own — see the ROADMAP. In short: `optiscaler` is
`available` but its extraction step cannot open the `.7z` upstream
actually publishes, and `ofxr-bridge` step 3 moves a file no release has
ever shipped.
An agent copying `moddin-agent/templates/registry-override.yaml` produced
a recipe that failed on its first step. The template wrote
`keyField: registryKey`, `nameField: registryValueName`,
`typeField: registryValueType` — names the runner has never read. The real
params are `key`, `value`, `type` and `data`/`dataField`, and a field is
read by templating it. The failure is immediate and unambiguous
("registry-write: key is required"), which is at least how a copy was
discovered at all.

Three other things in the same file were teaching the wrong lesson:

- The header suggested the template was for "redirecting an OpenXR runtime
  for the current user". There is no per-game OpenXR registry key; that is
  the `openxr-helpers` defect, now `planned`, and the template was
  repeating it to the next author.
- `checks:` declared `key-exists` with `kind: file-exists` on a registry
  path. A `file-exists` resolves inside the game folder and can never match
  a registry key, so the check could not pass — a declared check that fails
  for every user is worse than no check. Removed, with the reason recorded.
- The header told the agent to decide whether to remove the value on
  uninstall, "most cases: yes" — but `run_uninstall` resolves no config, so
  a templated uninstall step renders to nothing and cannot succeed. The
  template shipped one. It now says so, because the same trap is what made
  the shipped `openxr-helpers` uninstall chain unusable.

The contract documents the real param names, that `key`/`value`/`data` are
templated, that `dataField` accepts a non-string field so a `number` can
carry a `REG_DWORD`, that the delete is idempotent and why, and — the part
that keeps being rediscovered — that a registry write is **not a file
operation**: it plans no target, records no transaction, is therefore
outside Undo and invisible to `is_capability_installed`, and no check kind
can read it back.
Every entry in `KNOWN_DEFECTS` is gone, and the validator now reports
zero open data defects across 6 games, 5 engine presets, 9 recipes and 66
commands. All eight were the same mistake wearing different clothes: **the
catalogue listing a module id that nothing implements.**

They survived as long as they did because none of them crashed. Every one
was `status: planned`, so the game page rendered a disabled "Planned"
card. Nothing failed, so nothing looked wrong — and meanwhile the app was
advertising five ids it cannot act on.

The decisions, and why each went the way it did:

- **`cyberpunk-2077` → `uevr`**, filed twice (`recipe-engine-mismatch` and
  `preset-denies-module`). The comment there framed it as "either the
  recipe learns about REDengine, or the module goes away" and left it
  open. It is the second one. UEVR is an Unreal Engine VR injector and
  Cyberpunk 2077 is REDengine; the redengine preset says in its own
  description that Moddin does not bundle a UEVR-style injector for it.
  Teaching the recipe about REDengine is not a data fix, it is asking a VR
  injector to support an engine it has never supported, and whether that
  is even possible is not a question this repository can answer. One
  decision, two baseline lines, and the game is left with the VR stack
  that does apply.
- **`doom-2016` → `kharvox-vr`**, filed twice. The idtech preset says the
  desktop shortcut is the only reusable mod that ships today.
- **`dawnwalker` → `graphics-profile`, `vortex-migration`**. Declared
  intention with nothing behind it — no preset, recipe, module, command,
  or even a ROADMAP item.
- **`dead-island-2` and `stalker-2` → `cheeky-foveated-dlss-uevr`**. A
  near-duplicate of `cheeky-foveated-dlss` under an id nothing implements.

Each removal leaves a comment saying what was dropped and what would bring
it back, so the decision is reviewable rather than merely gone.

The baseline is not deleted, it is emptied, and the record of the eight
stays in the file above the array. An empty list otherwise reads as
"nothing was ever wrong" — and the next person to add a `planned` module
with nothing behind it should be able to see that this exact mistake was
already made once and unmade once.
`useAiAssistant` sent `config: { values: {} }` for every recommendation,
so any recipe declaring a required field installed nothing and failed
inside the first step it had already begun — after the user confirmed and
after the signature gate had let it through. Same defect the Community
panel had, one layer up, and it survived there being fixed.

The schema comes from the signed catalogue entry's own `configSchema`,
which is the copy the Community panel already reads and which the
signature fetch was already holding in hand. An id the catalogue does not
carry is asked of the registry instead, whose `capability_get` errors on
an id it does not hold — so a throw is positive evidence of "unknown"
rather than an empty spec.

**Unknown means refused, not assumed empty.** A recipe whose schema
cannot be read has not been shown to have no required fields, and
installing it on that assumption is the exact failure this replaces. That
is why `readConfigSchema` distinguishes *known-empty* (`null`, `[]` —
what Rust's `#[serde(default)]` and the catalogue's JSON produce) from
*unknown*, and only the second refuses.

Every recommendation's config is planned before the first install, over
the same shared resolver the panel and the collections use, and the
refusal names the capability, the fields, and where to go fill them in —
in the voice of the signature gate directly above it, which is the pattern
this file already had.

Red before green was performed, not asserted: seven new tests failed
against unmodified source (`expected "vi.fn()" to not be called at all,
but actually been called 1 times`, and a `config: { values: {} }` versus
`{ preset: 'ultra', … }` diff) and pass after. 23/23.

The two test failures this pass inherited were not its own and are fixed
here: `catalog.test.ts` and `useCapabilityModules.test.ts` both pinned
"the catalogue has no value for this" to ids whose absence was the bug,
so they failed the moment the catalogue was fixed. The first now uses
`uevr`; the second uses `ofxr-bridge`'s `%LOCALAPPDATA%` path fields,
which are the values the app genuinely cannot know.
…ds of it were wrong

The item read "translate `safetyNotes`, `configSchema[].description` and
`displayName`". Shipping that as written would have meant machine
rendering the safety warnings — the text a reader relies on when a recipe
wants UAC, or says a transaction rollback is partial.

So the item is split by what each field *is*. `displayName` is a short
label behind a stable id, and the app already rewrites `description` the
same way: it is in scope, and gets a table beside `capabilityDescriptionKey`
with the same exhaustiveness guard. `safetyNotes` and
`configSchema[].description` are the recipe author's prose, and the
codebase already wrote the principle down — about community recipes, it
says *"that text is the author's, not the backend's, and the app does not
get to rewrite what a person wrote."* That was written to stop the app
paraphrasing an outsider. It bites harder on first-party warnings: a note
the reader is relying on is the last thing to put through a translator
they cannot audit.

The item also records how to reverse the call if it is wrong — per-field
opt-in with a human reviewing the copy, not a blanket pass — so this
reads as a decision rather than a deferral.
The `optiscaler` recipe was `status: available` with a real catalogue entry
and a real URL, and **could not install**. Upstream publishes exactly one
OptiScaler asset and it is a `.7z`; the recipe staged it as
`optiscaler.zip` and handed it to a step that builds a `zip::ZipArchive`.
The install died on its first extraction, every time.

`extract-zip` now dispatches on the archive's **magic bytes**, not its
name. `PK\x03\x04` and `PK\x05\x06` are zip, `7z\xBC\xAF\x27\x1C` is 7z,
and anything else is refused with the format named. Magic bytes rather
than the extension because the extension here is demonstrably a lie, and
because both decoders take a byte slice and open what they recognise —
sniffing first is what lets the step hand the payload to the reader that
can read it. A format it cannot name writes nothing and says so; a step
that extracts an empty directory and reports success is the worst outcome
available here.

**No new dependency.** `sevenz-rust 0.6.1` was already in `Cargo.toml` and
already used by `optiscaler.rs:500`, which does this correctly. The
declarative step simply never dispatched on format. `Cargo.toml` is
untouched.

The proxy rename was a no-op for this recipe too, and that is now
expressible rather than special-cased. A new optional `payload` /
`payloadField` param means *the recipe names the main payload DLL, and it
takes the proxy name*; the step resolves the basename to a member the way
`optiscaler.rs::find_payload_root` already does. `optiscaler.yaml`
declares `payload: OptiScaler.dll`. A recipe that names no payload keeps
the old `reshade64.dll`/`dxgi.dll` rule, so `reshade.yaml` behaves
identically — there is a regression test for exactly that. A declared
payload the archive does not ship is a hard error, not a partial install.

The archive layout was verified rather than assumed, because "the members
might sit inside a wrapper folder" was the one thing left that could make
`available` dishonest. The real 55,016,448-byte asset was downloaded and
hashed — `575cb4df…80ef0ad`, matching both GitHub's own `digest` field and
the committed catalogue value for the third time — and listed: 23 members,
16 of them at the root including `OptiScaler.dll`, the only subdirectories
being `D3D12_Optiscaler/` and `Licenses/`. It ships a file literally named
`!! README_EXTRACT ALL FILES TO GAME FOLDER !!.txt`. It is rooted, and
extracting members at their archive-relative paths is what upstream asks
for.

Seven new tests, red before green and proven so: six failed against the
pre-change step, including `a 7z payload extracts even when the file is
named .zip` reporting `invalid Zip archive: Could not find EOCD`, and a
refusal case that could not name the format. The seventh was a wrong
assertion of mine rather than the bug — the pre-existing rule collapses
every member named `reshade64.dll` or `dxgi.dll` onto one proxy path, and
with two such members the last write wins silently. That collision is
real and is left as a known gap rather than fixed here.

`moddin-agent`'s step-kind table gains `payload` / `payloadField` so the
agent's `get_step_kinds` tool stops advertising a step it cannot describe.
Third recipe in a row claiming an effect it cannot have, and the first
one whose failure was unconditional rather than conditional.

The install's third step moved
`{extractedDir}/XR_APILAYER_XRFrameBridge_manual-{implementationVersion}.json`
into the OpenXR ApiLayers directory. **No published release ships that file
at any depth** — 0.2.1 and 0.2.0 of `tig3rmast3r/OFXR-Bridge` were both
listed in full. The `move-file` failed on every install, and the two steps
after it, writing `tray.ini` and launching the tray, never ran.

The file is not missing by accident. The tray generates the manifest and
registers the layer itself, at arm time, after the user picks a backend
from its system-tray UI. Upstream's own install steps are extract, run
the tray, choose the backend. A chain that "promotes" the manifest
describes a step that does not exist upstream, so the blocker is a
missing capability rather than a missing file: closing it needs a step
that runs an interactive arming flow and waits for it. There is no such
step.

Registering the layer is now expressible and was not before — a
`registry-write` with `data`/`dataField` writes the `HKLM\...\OpenXR\ApiLayers`
value the loader reads. What is still missing is the manifest that
registration points at, which is the half the tray produces.

The safety note this replaces read as though the promoted manifest were
a real file whose absence was an edge case. It was unconditional, and a
note describing a failure that always happens is worse than no note,
because it reads as reassurance.

`ofxr-bridge` is `planned` with an empty chain, and both shipped
collections drop it. They are down to `optiscaler` alone, which is the
honest set: one member, which installs. The collections' own comments
already said why — naming a planned member advertises something the runner
turns down — and this is the second time that argument has been paid for,
the first being the deliberate absence of `uevr`. Two collection tests
were asserting the old member count and caught it.

`optiscaler`, the other half of both bundles, now actually installs: the
previous commit made its `.7z` readable, and the archive is rooted with
`OptiScaler.dll` at the top.
…lds stay

Closes the half of the localization item that was actually right to
close. `displayName` is a short label behind a stable id, and the app
already rewrites `description` the same way, so it gets a
`CAPABILITY_NAMES` table beside `capabilityDescriptionKey` with the same
exhaustiveness guard. An undeclared id yields `undefined` through the
same own-property lookup, never a prototype member and never a blank, so
a community recipe keeps its author's own title.

The two fields that do **not** get translated are a decision, not an
omission, and the ROADMAP item now records the reasoning rather than
leaving the item open. `safetyNotes` and `configSchema[].description` are
the recipe author's prose, and this codebase already wrote the principle
down about community recipes: that text is the author's, not the
backend's, and the app does not get to rewrite what a person wrote. That
was written to stop the app paraphrasing an outsider; it bites harder on
first-party warnings, because a note the reader is relying on — one
saying a registry write needs UAC, or that a rollback is partial — is the
last text to put through a translator they cannot audit.

The distinction the worker drew, which is the right one and is now in the
code: a capability's `displayName` is a recipe field and is translated;
a **collection member's** `displayName` is written by whoever published
the collection and is left alone. Two fields, same name, different
authors.

All nine names written by hand in `en`, `pt-BR` and `es`, reusing each
bundle's existing wording. Upstream spellings kept where one exists
(UE4SS, BepInEx, ReShade, OptiScaler, OFXR Bridge, REFramework, Cheeky,
UEVR). BepInEx is identical across all three because it is a bare
product name, not an untranslated row.

Red before green was performed: 6 failed / 42 passed against pre-change
source. The undeclared-id fallback test passed red — that discipline
already existed, so it is asserted rather than introduced.
…se the baseline item

Three gaps surfaced while making recipes honest, and all three are the
same shape: a shipped recipe wanted a capability the step vocabulary does
not have. They are recorded as items rather than folded into the recipes
that found them, because a gap written down in the file that hits it gets
rediscovered every time that file is read.

- **A member filter on `extract-zip`.** Named by REFramework: its release
  says non-VR users must extract *only* `dinput8.dll`, and extracting the
  rest may crash the game. Staging plus `move-file` installs the
  flat-screen case correctly and silently gives a VR user no OpenXR
  support at all.
- **Environment-variable expansion in `render_template` / `resolve_path`.**
  Two recipes, one missing capability. Four `ofxr-bridge` fields resolve
  under `%LOCALAPPDATA%` and no step can reach them, which is why a
  collection naming it refuses with `needsConfig` instead of writing
  something plausible and wrong. The same gap keeps `openxr-helpers` open,
  because rendering a Windows path into the preference JSON also needs
  escaping — unescaped, `read_game_preference` returns `None` and the
  override silently does nothing.
- **The default proxy rename collides.** With no `payload` declared, every
  member basenamed `reshade64.dll` or `dxgi.dll` lands on the same path
  and the last write wins. Left unfixed on purpose: refusing the collision
  is a behaviour change to a shipped rule, and that deserves its own diff
  rather than riding along with the fix that found it.

Also closes the baselined-catalogue-defects item. It was still open after
being finished, which is its own small lesson: a roadmap is a document, and
a document that is not updated with the work is a document that lies.
The profile feature wrote exports to
`%LOCALAPPDATA%\Moddin\profiles\exports\` and reported the path. The user
had no say in where the file went and no way to open a profile they did
not already have on this PC — which is the only direction that matters
for the feature's actual purpose, setting a rig up on another machine.

`tauri-plugin-dialog` is added and pinned to the 2.7 line, because 2.8
requires `tauri = "^2.12"` and the graph is deliberately on 2.11.5. Export
now opens a native save dialog and receives back the path the file really
landed at; import gains a Browse button that opens a native open dialog
and drops the answer into the path box, so a picked file and a pasted path
are the same thing from there on. A dismissed dialog is not a failure: it
resolves to "nothing happened", leaves the box and the current preview
exactly as they were, and raises no notice or error.

The webview is granted no dialog permission
(`src-tauri/capabilities/dialog.json`). The dialogs open from the
`profile_files` commands, so a profile can only ever be written to a path
the user picked.

**The dialog channel was broken and clippy said so.** Both callbacks did
`let _ = sender.send(chosen)`, which drops the send future *unpolled* —
and a channel send only enqueues when polled. The value would never have
arrived, `recv()` would have returned only once the sender was dropped
with the closure, and every single save would have reported "the dialog
closed without an answer". The Rust tests passed regardless, because they
cover `finish_save`, `validate_destination` and `read_profile_at`
directly and a native dialog is not reachable from jsdom. Fixed by
spawning the send on the async runtime, which is what the codebase
already does for its other async boundaries.

Both test files were still written against the old path-based API and
mocked `writeProfileFile`, a function the service no longer has — a
phantom export that made them assert an API nothing calls. Converted to
the dialog API, and the two "opens the file" tests now assert the path
the *dialog* reported rather than a directory Moddin used to choose.

The panel helper read the profile by position, `panelRowButtons()[1]`.
Adding the Browse button to that row made index 1 the wrong button and
took ten tests with it. It selects by label now, with the reason
recorded, because an index into a row of buttons is a test that breaks
the next time someone adds one.

316 Rust tests, 379 frontend tests, and fmt, clippy, typecheck,
architecture, catalogue and step-kind parity all green.
… carried

The item was open after being finished, and it is worth one line that it
was written down as *not started* with a false reason: the real blocker
was not that it needed Cargo.toml, which is true and was scheduled
around, but that the storage layer it was waiting on had zero callers.
…hips with no frontend

No audit id — new, found while auditing feat/agent-mcp to decide whether that
branch could be deleted. It could not be deleted for free: one of its seven
unmerged commits is a fix that was never merged and is release-blocking.

The failure, concretely. `tauri`'s build script takes `let dev =
!custom_protocol` and emits `cargo:dev`; tauri-build then runs `cfg_alias("dev",
is_dev())` for the whole crate; and `Manager::get_app_url` resolves the app URL
to `build.devUrl` — http://127.0.0.1:1420 — instead of the embedded
`frontendDist`. Every window the app opens loads a dev server that is not
running.

Nothing announces it. `cargo check` passes, `cargo build --release` passes,
`beforeBuildCommand` runs, `dist/` is produced, the installer builds, installs
and launches. The only symptom is a blank WebView2 on a user's machine.

Not a theory — the evidence was already on this machine. Two release build
directories, same tree, same day:

  moddin-desktop-05b7570174302b52  02:25  cargo:rustc-cfg=dev  no codegen-assets
  moddin-desktop-8570ac569a8989ff  18:06  dev=false             4 files, 200.5 KB

The 18:06 one is this commit. Those 4 files are the brotli-compressed form of
the 4 files in `dist/` (761 KB raw), so the correspondence is exact rather than
impressionistic. The 02:25 one had no `tauri-codegen-assets` directory at all.

The fix arrived on feat/agent-mcp as ef9553c and was never merged, because the
roadmap read that branch as product work pending a port rather than as a fix
belonging here. A merge is not the only way a commit reaches a release, and "not
in main" is not the same as "not needed".

The 9-line comment above the dependency is not decoration. `features = []` reads
as leftover scaffolding, and without the reason the next person tidies it away
and reintroduces a bug no test in this repository can see.

Deliberately not done: no installable artifact is produced by this commit. The
release build still refuses with E0080 until the updater pubkey placeholder is
replaced, which is the documented fail-closed path in docs/UPDATER.md rather
than a regression. Proving that an *installed* app renders its UI needs a key
and a dry-run tag, and stays open.
Same finding, the half that stops it coming back.
scripts/check-bundle-features.mjs asserts that every crate depending on `tauri`
enables `custom-protocol`.

It walks the repository for Cargo.toml files rather than naming
src-tauri/Cargo.toml. There is one manifest today; if there are three tomorrow
this covers all three, and ROADMAP.md records twice that a guard is only as good
as its inventory of sources — the step-kind guard compared four of five lists
and the fifth had already drifted. `tauri-build` is deliberately not checked: a
different crate, a build dependency, and it does not take this feature.

It reads the manifest, not the artifact, and that limit is written into the
guard rather than left for a reader to discover. Cargo does not re-run a
dependency's build script when a feature flips, so a stale target directory can
still hold a binary built the other way — which is why reproducing the evidence
for the previous commit needed a 13.8 GiB `cargo clean`. The artifact-level
proof is the dry-run tag.

Evidence that it fires, by mutation. With the feature removed the guard fails
and names the file and the line:

  Bundle feature check failed: src-tauri/Cargo.toml:42 enables tauri features []
  but not `custom-protocol`. Without it the bundled app serves build.devUrl
  (http://127.0.0.1:1420) instead of the embedded frontendDist, and the only
  symptom is a blank window on a user machine.

and passes again once restored. It runs through `node` directly and is the
first step in CI, before `npm ci`, so nothing can fail ahead of it.

docs/UPDATER.md: the dry-run procedure said "install it on a spare machine" and
never asked whether the window rendered anything. An installer that launches a
blank window passes every step of its own checklist, which is why this got
through a release cycle. The procedure now requires `cargo:rustc-cfg=dev` to be
absent and `tauri-codegen-assets` to be non-empty *before* a tag, and says that
`cargo clean -p moddin-desktop` is mandatory when the feature was just added.
…s, refuse the proxy collision

F-10, plus the three roadmap items found while closing it. One story: the runner
could not express what recipes already promised, and in three places it guessed
instead of refusing.

1. `include` / `exclude` on extract-zip. An archive shipping a superset of what
one game wants is the normal case for injected-DLL frameworks; REFramework
v1.5.9.1 ships flat-screen and VR payloads together and its release note says a
player without a headset must extract only dinput8.dll. `include` selects,
`exclude` then removes, `*` stays inside one path segment, `**` crosses any
number, a pattern with no `/` is a basename matched at any depth, and matching
is ASCII case-insensitive because the filesystem is. `?` and `[` are LITERALS:
a Windows member name cannot contain them, so reading them as wildcards could
only ever drop a member the recipe asked to install. The filter runs *before*
`payload` resolution and the proxy rename, because that is the order in which
the two compose — a recipe that filters to one DLL still needs `payload` to
name it. A filter that selects nothing is refused, naming both pattern lists
and the archive's member count, with nothing written.

2. `%VAR%` expansion in render_template and resolve_path, and `format: json` on
write-text-file. Four ofxr-bridge fields declare `type: path` under
%LOCALAPPDATA% and no step could reach them; a committed value would have had to
be a fabricated C:\Users\<someone>\ path that breaks for every other user. A
variable the process does not have stays LITERAL, because a template is also
prose and a file's content has `%` for unrelated reasons. No new exemption in
the install-root rule: an expanded path lands in resolve_write_target and gets
exactly the existing treatment — absolute honoured, which is the pre-existing
path-typed-field exception, and a `..` walk out of the root refused. Pinned by a
test whose variable *is* `..`. `format: json` inserts each {param} as escaped
JSON string content, keeping the recipe's own quotes and punctuation, and
parses the result with serde_json *before* create_dir_all — so a refusal leaves
no file and no directory. That ordering is the fix rather than a nicety: the
failure being ended is a Windows path rendered unescaped into a preference
document that read_game_preference then silently answers None for.

3. The default proxy rename. With no `payload` declared, every archive member
basenamed reshade64.dll or dxgi.dll was renamed onto one path and the last one
won silently. Now refused, naming the destination, the members claiming it, and
the fix. Determinism is tested as a property rather than an outcome: two
archives with the same members in opposite order produce a byte-equal message.
The key is lowercased because Windows compares paths case-insensitively, so
DXGI.dll and dxgi.dll are one destination.

Evidence: 336 cargo tests pass, up from 325. 379 vitest pass. clippy clean at
zero warnings with no new #[allow]. fmt, the kind-parity guard, the catalogue
validator, the frontend architecture guard and typecheck all pass. The new
tests are named in the roadmap entries.

Behaviour change beyond the stated item, flagged as such: `render_template` no
longer consumes a `{...}` run it cannot resolve. The old scanner took
everything up to the next `}` and put it back, which for a JSON template means
the object's opening brace swallows the first placeholder —
{"runtimePath": "{preferredRuntime}"} rendered the whole
{"runtimePath": "{preferredRuntime as one unresolvable name. `format: json` is
not implementable without this. Output is byte-identical for a template that is
nothing but placeholders; only the resume point differs. The same reasoning
forced a fix in the recipe-wide field scanner, which would otherwise have
reported {"runtimePath": "{preferredRuntime as an undeclared field and failed
every recipe the JSON format exists to enable.

Deliberately not done:
- `reframework` stays `planned`. The engine can filter now; the recipe still
  needs a per-user VR/no-VR field, patterns verified against a real v1.5.9.1
  archive rather than against this roadmap's summary of it, and a decision
  about how a flat `include` list branches on the answer at all.
- `openxr-helpers` stays `planned`. Both named gaps closed in the runner, but
  the recipe has no `gameId`, `run_uninstall` resolves no config, and nothing
  yet answers what Undo does for a file outside the install root, which the
  transaction store will not back up.
- `chain_artifacts` in capability_runner.rs still does not know about `include`
  and `exclude`, so a check can point at a filtered-out member and pass the
  guard while failing its own check at install time. Latent, not live — no
  shipped recipe filters yet. Fixing it means deciding what a *filtered*
  archive's artifact set is, which is an authoring-policy question. New
  roadmap item, left open rather than guessed at.
- The community validator does not check `%VAR%` against the runner's
  [A-Za-z_][A-Za-z0-9_]* rule. That lives in moddin-community-capabilities and
  is a change in another repository.

ROADMAP.md rides in this commit rather than with the two above it: a single file
cannot be split without an interactive add, and its new "Now" entry describes
work those commits did.
`Clippy` failed on the first CI run this branch ever took, and the error was
not a lint:

  resource path `..\moddin-runtime\node.exe` doesn't exist
  error: failed to run custom build command for `moddin-desktop v0.1.0`

The mechanism, since the message alone does not localise it. tauri.conf.json
lists `../moddin-runtime/{node.exe,moddin-agent}` under `bundle.resources`.
Both are gitignored and generated by `moddin-runtime/bundle-runtime.ps1`. The
tauri build script copies every entry in that list **unconditionally** — not
gated on the `custom-protocol` feature, not on dev versus release:

  let mut resources = config.bundle.resources.clone()…;
  BundleResources::List(res) =>
      copy_resources(ResourcePaths::new(res.as_slice(), true), target_dir)?

and `copy_resources` iterates `ResourcePaths`, whose `resource_from_path`
returns `ResourcePathNotFound` for a path that is not there
(`tauri-utils/src/resources.rs:205-208`). So **any** cargo invocation that
compiles the crate needs that folder staged first, on a fresh checkout.

Why it went unnoticed here and not on main: `Format check` and `Clippy` were
inserted above `Bundle moddin-runtime` by the commit that made the gates
blocking. `cargo fmt` does not compile, so it passed where it sat and nobody
had a reason to look further down. `cargo clippy` compiles, runs the build
script, and died. On main the bundle step already sat above every Rust
compile, and its last run was green in 7m16s — the difference is the ordering,
not the tree.

Fixed by moving both gates below `Verify runtime bundle`, restoring the
invariant main had: nothing compiles before the runtime is staged. The comment
block says so explicitly and states the rule for the next step that compiles
Rust, because the failure mode is a build-script error naming a gitignored
file, which reads like a broken checkout rather than a step ordering.

Not verified here: this workflow only runs on GitHub. The YAML parses and the
step list is asserted above, but the run that proves the fix is the next push.
@marcoasjunior
marcoasjunior merged commit 36ce65f into main Oct 1, 2026
1 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant