17 KiB
Spec A - Multi-branch picker
Date: 2026-04-30 Status: Draft v2 (incorporates spec-review fixes #1–#13) Out of scope: B (collection expansion + live progress), C (build context), D (dep "Add" button), E (precacher), G (cleanups). See §11.
1. Summary
Some Steam Workshop items ship multiple mod.info files under one wsid (canonical example: AuthenticZ → AuthenticZBackpacks+, Authentic Z - Current, AuthenticZLite). Today every parsed mod_id flows into MODS_LINE, including alternates the user must pick exactly one of. This spec adds a per-wsid picker UI with localStorage persistence and a new POST /api/resort endpoint that recomputes load order and warnings for the chosen subset, without re-hitting Steam.
2. Problem
- AuthenticZ (wsid
2335368829) yields threemod_parsedrows:AuthenticZBackpacks+,Authentic Z - Current,AuthenticZLite. They are mutually exclusive branches. - The author left
incompatible_modsempty on all three, so we have no metadata signal that they are alternates. - Today's
MODS_LINEis";".join(SORTED_ORDER), so all three branch IDs land in the output. PZ refuses to start with conflicting mods, so the file looks valid but bricks the server - silent corruption. - Other multi-mod packages exist where every
mod_idshould load (cooperative content packs). The system must support both shapes.
3. Trigger rules
- The picker UI fires iff a wsid has ≥2 rows in
mod_parsed. - Row count is the only trigger. Author metadata does not gate visibility - see §5 for what it changes.
4. Default selection rules
For each picker-eligible wsid:
- If any
mod_parsed.incompatible_modsfor that wsid lists anothermod_idfrom the same wsid → default selection = firstmod_idonly. - Else → default selection = all
mod_ids ticked.
"First" tiebreaker: ORDER BY parsed_at ASC, mod_id ASC. worker.process_one parses sequentially in a for mip in mod_info_paths: await conn.execute(UPSERT_MOD_PARSED, ...) loop (one statement per mod.info, no gather/to_thread), so parsed_at = now() produces strictly increasing microsecond values per row in practice. mod_id ASC is the defensive tiebreaker for the theoretical sub-microsecond case. This is a spec-locked decision - revisit if the resulting "primary" branch feels wrong on real inputs.
Default-selection safety net (fix for review #1)
The default-all-ticked path covers the canonical AuthenticZ case (3 rows, all incompatible_mods=[]) and would otherwise emit the same bricking config that motivated this spec. To prevent silent corruption, the API emits an additional warning whenever the default leaves all branches selected without any author signal:
- If a wsid has ≥2 mod rows, AND every row's
incompatible_modsis empty, AND the user's current selection includes all branches (i.e., they haven't unticked any), emit aWARNINGSentry:tag: "ambiguous-multi-branch",level: "amber",msg: "X branches selected from <wsid title> - author didn't declare alternates; verify these aren't mutually exclusive (e.g., AuthenticZ Lite vs Current). Expand the row to pick one." - The warning clears as soon as the user makes any explicit selection (any branch unticked, or - in radio mode - any branch chosen).
- Picker UI remains opt-in; this rule guarantees the user sees a yellow flag without having to expand every multi-branch row.
5. UI mode rules
- Default: checkboxes (multi-select).
- Upgrade to radios (single-select; exactly one always picked) iff any
mod_idfor the wsid lists anothermod_idfrom the same wsid in itsincompatible_mods. - Cross-wsid incompatibilities (mod A in wsid X marks mod B in wsid Y) do not trigger radio mode for either wsid; they continue to flow through the existing Warnings system.
6. UI placement & interactions
- Inline row expansion in the existing
ModTable(sortof-app.jsx:306). No new top-level component. - A multi-branch wsid renders as one parent
<tr>, occupying the slot the first selectedmod_idfrom that wsid holds inSORTED_ORDER. Other selected branchmod_ids from the same wsid do not render their own rows - they live inside the expanded panel. - Mod ID cell affordance:
- Unresolved (no user interaction yet, no hydrated selection):
▾ N branches - Resolved (user touched it, or hydrated from
localStorage):✓ X of N
- Unresolved (no user interaction yet, no hydrated selection):
- Click affordance to toggle expansion. Multiple wsids may be expanded simultaneously - single-pass triage on a 450-mod collection.
- Expanded panel: a single
<tr>withcolSpan={COLUMN_COUNT}containing per-branch rows of[checkbox|radio] mod_id - name - cat - deps - pos.COLUMN_COUNTis a single source-of-truth constant (today 6; Spec C will add a 7th column for build-context). Do not hardcode the integer. Match existing column rhythm so zebra striping still reads. - Single-mod wsids render unchanged.
/api/resortfailure mode (review #11): on 5xx response, retain the priorMOD_DB/SORTED_ORDER/MODS_LINE/WARNINGSstate and emit a transientWARNINGSentrycouldn't recompute sort - try again(level=red) with a retry button. Never apply a partial response.- Parent row's category / deps / load cells reflect the first selected branch's values; if zero are selected, the parent row remains visible with affordance
✓ 0 of Nand-in the data cells, and contributes nothing toMODS_LINE/SORTED_ORDER. Display position for zero-selected rows is implementation-defined (e.g., previous slot, or sorted by anymod_idfrom the wsid) since the wsid no longer appears inSORTED_ORDER.
7. Persistence
localStoragekey:sortof.branch.selections. One key total - hydrate in a single read.- Value: JSON-serialized object keyed by wsid → array of selected
mod_idstrings.
{ "2335368829": ["Authentic Z - Current"], "2169435993": ["modoptions"] }
- Hydration on app mount: read once, merge into in-memory
branchSelectionsstate. - Eviction: if a stored
mod_idis no longer present in the currentMOD_DBrows for that wsid (cache invalidated upstream, mod.info changed, etc.), drop it silently. Do not warn. - Radio-mode invariant guard (review #2): if eviction would leave a radio-mode wsid with zero selected
mod_ids, fall back to the §4 default (first-only). Radio mode's "exactly one always picked" invariant must hold post-hydrate. - Single-mod wsids never write to this object; absence implies "use default".
- Cross-tab sync (review #8): App attaches a
window.addEventListener('storage', ...)listener; on asortof.branch.selectionsstorage event, replace in-memorybranchSelectionswith the new value and trigger a single/api/resort. Last-writer-wins on the underlying storage value; in-tab state stays coherent.
8. API impact
- No change to
POST /api/sortrequest or response shape. - New endpoint:
POST /api/resort, taking the current selection and returning a fresh order + warnings without re-hitting Steam.
{ "selected_mod_ids": ["modoptions","tsarslib","Authentic Z - Current"] }
- Response: same shape as
/api/sortwithstatus:"success"andpending:[]. Backend filtersmod_parsedrows to the supplied set viaWHERE mod_id = ANY($1::text[])(parameterized - review #9), runsmlos_sort, returns updatedSORTED_ORDER,MODS_LINE,WARNINGS,MOD_DB. No DB writes. WORKSHOP_ITEMS_LINEis not affected by selection - wsid stays subscribed regardless of whichmod_ids are enabled. Matches PZ'sWorkshopItemsvsModssemantics.
Scope, auth, validation (fix for review #3, #9, #10, #12)
- Stateless. No session token, no per-user partition.
mod_parsedis a shared cache; concurrent drain UPSERTs and/api/resortSELECTs serialize via asyncpg row locks. Multi-tenancy is out of scope for v1; if added later, expect asubmission_idon/api/sortand/api/resort. - Unknown
mod_idhandling (review #12): server silently dropsselected_mod_idsnot present inmod_parsed(matches §7 client semantics) and logs at INFO. If the entire selection is empty after the drop, return HTTP 400 - the client can recover by re-running/api/sort. - Input validation.
selected_mod_idsmust be a JSON array of strings, length ≥1 and ≤500, each string ≤256 chars. Reject anything else with 400 before touching the DB. PZmod_ids legitimately contain spaces,+,-, and apostrophes - the parameterizedANYpattern handles them safely; no string interpolation anywhere (review #9). - Rate limiting (review #10): not implemented at the FastAPI layer. Recommend a Caddy-level
@rate_limitmatcher on/api/sortand/api/resortbefore any public exposure beyond the current Tailscale-only bind. Documented as a known gap.
Sequenced requests (fix for review #5)
- The frontend tags every
/api/resortPOST with a monotonically increasing client-side sequence number (in-memory counter on App, not part of the request body - sent as headerX-Sortof-Seqor tracked via the issuing call site). - When a response arrives, compare its sequence number against the latest issued; if older, drop the response without applying it (UI keeps current state, last-issued response wins). Prevents stale-response overwrites under rapid toggling.
9. Data assumptions
- Schema column is
mod_parsed.incompatible_mods(TEXT[]) - names already stripped of any leading\per the B42 parser fix shipped today. mod_parsed.parsed_atordering verified (review #4):worker.process_oneparsesmod.infofiles sequentially withfor mip in mod_info_paths: await conn.execute(UPSERT_MOD_PARSED, …). Each upsert is its own asyncpg statement (auto-commit, no transaction wrap), andparsed_atisnow()evaluated server-side per statement. Sequential awaits + asyncpg RTT > 1µs ⇒ strictly increasing microsecond values in practice.mod_id ASCis a defensive tiebreaker for the theoretical sub-µs collision; no ordinal column exists in the schema and adding one is out of scope for this spec.- Dangling-deps detection (review #13) already exists in
mlos_sort.sort_mods(mlos_sort.py:432-437):enabled = set(by_id.keys())thenmiss = [r for r in mod.requirements if r not in enabled]per mod. Callingsort_modswith a filtered subset on/api/resortautomatically produces the new missing-dep warnings; no changes tomlos_sortare needed. - Frontend already has
incompatible_modsavailable asm.conflictson eachMOD_DBrow (adapters.py:94). - This spec consumes the
MOD_DB/SORTED_ORDER/WARNINGSshape currently produced byapp.py+adapters.py. Per-build variant filtering is Spec C; selection here operates on the fullmod_idcorpus the API returned.
10. Open questions resolved
- Client-side filter vs API round-trip. Client-side filter for the row affordance and parent-row rendering; server round-trip via
POST /api/resortfor sort + warnings recompute. Justification: instant feedback on tick/untick UX, but warnings are dependency-driven and need realmlos_sortevaluation. Pure-client would require portingmlos_sortto JS - far worse than a 50ms POST to a hot Postgres connection. SORTED_ORDERrecompute strategy. Re-runmlos_sorton the selected subset viaPOST /api/resort. Justification: when the user unticksAuthenticZLiteand another mod requires it, the warnings list and possibly the topological order both change. Filtering the previousSORTED_ORDERpost-hoc misses the new missing-dep warning, defeating the picker's safety value.- First-
mod_idtiebreaker for default selection.ORDER BY parsed_at ASC, mod_id ASC. Schema-deterministic and matches insertion order fromworker.process_one. Flagged in §4 as a lockable spec decision; revisit on real corpus. localStoragekey namespacing. Single keysortof.branch.selections, value{ [wsid]: string[] }. Thesortof.branch.prefix reserves namespace for any future per-feature storage; one key keeps hydration to a single read.
11. Out of scope
- B41/B42 build-context filtering (Spec C).
- Steam collection URL/ID expansion (Spec B).
- Dependency "Add" button (Spec C/D pair).
- Server-side persistence of branch choices.
- Live drain progress streaming (Spec B+F).
- Cleanups bundle (Spec G).
12. Acceptance criteria
- A wsid with N=1 mod row renders as a single normal row in
ModTable(no behavior change). - A wsid with N≥2 mod rows renders as one parent row with
▾ N branchesin the Mod ID cell. - Clicking the affordance expands a
colSpan'd panel listing all N rows with the correct input type (checkboxes by default, radios when intra-wsidincompatible_modsis non-empty). - Default selection matches §4 (all-ticked or first-only).
- Toggling a branch updates the affordance to
✓ X of Nand triggers aPOST /api/resortwhose response replacesMOD_DB,SORTED_ORDER,MODS_LINE,WARNINGSin app state. WORKSHOP_ITEMS_LINEis unchanged when branches toggle.localStorage["sortof.branch.selections"]is read on mount and written after every toggle, matching the §7 schema.- A stored
mod_idnot present in the currentMOD_DBfor its wsid is dropped silently on hydrate. - Multiple expanded panels can coexist (no auto-collapse on expand).
- Zero selected
mod_ids for a wsid: affordance reads✓ 0 of N; row contributes nothing toMODS_LINE/SORTED_ORDER. - When a wsid has ≥2 mod rows AND every row's
incompatible_mods=[]AND user has not unticked any branch, anambiguous-multi-branch(amber) WARNINGS entry is present; entry clears on first explicit user selection in that wsid (review #1). - Eviction of a stored
mod_idthat empties a radio-mode wsid falls back to §4 default-first; never leaves a radio-mode wsid with zero selections (review #2). /api/resortrequest carries a client-side sequence number; responses older than the latest issued are discarded without state mutation (review #5)./api/resort5xx response leaves prior state intact and surfaces a transient retry-able warning (review #11).- Server drops unknown
selected_mod_idssilently and logs at INFO; empty post-drop selection returns 400 (review #12). colSpaninModTablereferences a singleCOLUMN_COUNTconstant - not a hardcoded integer (review #7).storageevent listener installed; cross-tab toggle ofsortof.branch.selectionssyncs in-memory state and triggers exactly one/api/resort(review #8).
13. Test cases
- AuthenticZ canonical - wsid
2335368829, three rows, allincompatible_mods=[]. Expect: parent row▾ 3 branches, default = all ticked, mode = checkboxes. Untick two →MODS_LINEreflects one. Reload → selection persists. - Cooperative pack - wsid that ships 3 mods, all
incompatible_mods=[], deps reference each other. Expect: same affordance, default = all ticked, no behavior change for the user who never expands. - Mutually exclusive 2-branch - wsid where mod A's
incompatible_modslists mod B and vice versa. Expect: mode = radios, default = mod A only (first byparsed_at, mod_id). - Persistence across reload - pick a non-default subset, reload page; confirm hydration from
localStorage["sortof.branch.selections"]restores the selection on next sort. - Stored
mod_idno longer exists (checkbox mode) - manually inject a storedmod_idnot inMOD_DB, reload. Expect: silent drop, no console error, default applies. - Cross-wsid incompatibility - mod A (wsid X) lists mod B (wsid Y) in
incompatible_mods; both wsids have N=1. Expect: no picker UI, existing warning still surfaces. - Zero-tick wsid - untick all branches in a multi-branch wsid. Expect: parent row stays in
ModTablewith✓ 0 of N; no contribution toMODS_LINE/SORTED_ORDER/ numeric counts. - Radio-mode eviction-to-empty (review #6) - wsid in radio mode has stored selection
[X];Xis removed fromMOD_DB(e.g., upstream cache invalidation), reload. Expect: silent drop, then default-first applied, radio invariant preserved. - Default-all-ticked emits the safety warning (review #1) - load AuthenticZ-canonical without expanding the row. Expect: a
tag:"ambiguous-multi-branch"amber entry visible in WARNINGS. Untick one branch → entry disappears on next/api/resortresponse. - Stale resort response discarded (review #5) - issue toggle 1 (slow), then toggle 2 (fast) before #1 returns. Expect: only #2's response applied; #1 dropped on arrival.
/api/resort5xx (review #11) - stub the endpoint to return 500; toggle a branch. Expect: prior state retained, transient red warningcouldn't recompute sort - try againsurfaced with retry control.- Cross-tab sync (review #8) - open two tabs, toggle in tab A. Expect: tab B receives
storageevent and re-runs/api/resortwith the new selection. - Unknown selected_mod_id from server perspective (review #12) - POST
/api/resortwithselected_mod_ids=["modoptions","ghostMod"]whereghostModisn't inmod_parsed. Expect: 200 withghostModsilently absent from response; INFO log entry server-side. POST with all-ghost IDs → 400.