Files
sortof/docs/specs/2026-04-30-multi-branch-picker.md

17 KiB
Raw Permalink Blame History

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 three mod_parsed rows: AuthenticZBackpacks+, Authentic Z - Current, AuthenticZLite. They are mutually exclusive branches.
  • The author left incompatible_mods empty on all three, so we have no metadata signal that they are alternates.
  • Today's MODS_LINE is ";".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_id should 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_mods for that wsid lists another mod_id from the same wsid → default selection = first mod_id only.
  • 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_mods is empty, AND the user's current selection includes all branches (i.e., they haven't unticked any), emit a WARNINGS entry: 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_id for the wsid lists another mod_id from the same wsid in its incompatible_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 selected mod_id from that wsid holds in SORTED_ORDER. Other selected branch mod_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
  • Click affordance to toggle expansion. Multiple wsids may be expanded simultaneously - single-pass triage on a 450-mod collection.
  • Expanded panel: a single <tr> with colSpan={COLUMN_COUNT} containing per-branch rows of [checkbox|radio] mod_id - name - cat - deps - pos. COLUMN_COUNT is 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/resort failure mode (review #11): on 5xx response, retain the prior MOD_DB/SORTED_ORDER/MODS_LINE/WARNINGS state and emit a transient WARNINGS entry couldn'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 N and - in the data cells, and contributes nothing to MODS_LINE / SORTED_ORDER. Display position for zero-selected rows is implementation-defined (e.g., previous slot, or sorted by any mod_id from the wsid) since the wsid no longer appears in SORTED_ORDER.

7. Persistence

  • localStorage key: sortof.branch.selections. One key total - hydrate in a single read.
  • Value: JSON-serialized object keyed by wsid → array of selected mod_id strings.
{ "2335368829": ["Authentic Z - Current"], "2169435993": ["modoptions"] }
  • Hydration on app mount: read once, merge into in-memory branchSelections state.
  • Eviction: if a stored mod_id is no longer present in the current MOD_DB rows 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 a sortof.branch.selections storage event, replace in-memory branchSelections with 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/sort request 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/sort with status:"success" and pending:[]. Backend filters mod_parsed rows to the supplied set via WHERE mod_id = ANY($1::text[]) (parameterized - review #9), runs mlos_sort, returns updated SORTED_ORDER, MODS_LINE, WARNINGS, MOD_DB. No DB writes.
  • WORKSHOP_ITEMS_LINE is not affected by selection - wsid stays subscribed regardless of which mod_ids are enabled. Matches PZ's WorkshopItems vs Mods semantics.

Scope, auth, validation (fix for review #3, #9, #10, #12)

  • Stateless. No session token, no per-user partition. mod_parsed is a shared cache; concurrent drain UPSERTs and /api/resort SELECTs serialize via asyncpg row locks. Multi-tenancy is out of scope for v1; if added later, expect a submission_id on /api/sort and /api/resort.
  • Unknown mod_id handling (review #12): server silently drops selected_mod_ids not present in mod_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_ids must be a JSON array of strings, length ≥1 and ≤500, each string ≤256 chars. Reject anything else with 400 before touching the DB. PZ mod_ids legitimately contain spaces, +, -, and apostrophes - the parameterized ANY pattern handles them safely; no string interpolation anywhere (review #9).
  • Rate limiting (review #10): not implemented at the FastAPI layer. Recommend a Caddy-level @rate_limit matcher on /api/sort and /api/resort before 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/resort POST with a monotonically increasing client-side sequence number (in-memory counter on App, not part of the request body - sent as header X-Sortof-Seq or 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_at ordering verified (review #4): worker.process_one parses mod.info files sequentially with for mip in mod_info_paths: await conn.execute(UPSERT_MOD_PARSED, …). Each upsert is its own asyncpg statement (auto-commit, no transaction wrap), and parsed_at is now() evaluated server-side per statement. Sequential awaits + asyncpg RTT > 1µs ⇒ strictly increasing microsecond values in practice. mod_id ASC is 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()) then miss = [r for r in mod.requirements if r not in enabled] per mod. Calling sort_mods with a filtered subset on /api/resort automatically produces the new missing-dep warnings; no changes to mlos_sort are needed.
  • Frontend already has incompatible_mods available as m.conflicts on each MOD_DB row (adapters.py:94).
  • This spec consumes the MOD_DB/SORTED_ORDER/WARNINGS shape currently produced by app.py + adapters.py. Per-build variant filtering is Spec C; selection here operates on the full mod_id corpus the API returned.

10. Open questions resolved

  1. Client-side filter vs API round-trip. Client-side filter for the row affordance and parent-row rendering; server round-trip via POST /api/resort for sort + warnings recompute. Justification: instant feedback on tick/untick UX, but warnings are dependency-driven and need real mlos_sort evaluation. Pure-client would require porting mlos_sort to JS - far worse than a 50ms POST to a hot Postgres connection.
  2. SORTED_ORDER recompute strategy. Re-run mlos_sort on the selected subset via POST /api/resort. Justification: when the user unticks AuthenticZLite and another mod requires it, the warnings list and possibly the topological order both change. Filtering the previous SORTED_ORDER post-hoc misses the new missing-dep warning, defeating the picker's safety value.
  3. First-mod_id tiebreaker for default selection. ORDER BY parsed_at ASC, mod_id ASC. Schema-deterministic and matches insertion order from worker.process_one. Flagged in §4 as a lockable spec decision; revisit on real corpus.
  4. localStorage key namespacing. Single key sortof.branch.selections, value { [wsid]: string[] }. The sortof.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 branches in 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-wsid incompatible_mods is non-empty).
  • Default selection matches §4 (all-ticked or first-only).
  • Toggling a branch updates the affordance to ✓ X of N and triggers a POST /api/resort whose response replaces MOD_DB, SORTED_ORDER, MODS_LINE, WARNINGS in app state.
  • WORKSHOP_ITEMS_LINE is unchanged when branches toggle.
  • localStorage["sortof.branch.selections"] is read on mount and written after every toggle, matching the §7 schema.
  • A stored mod_id not present in the current MOD_DB for 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 to MODS_LINE / SORTED_ORDER.
  • When a wsid has ≥2 mod rows AND every row's incompatible_mods=[] AND user has not unticked any branch, an ambiguous-multi-branch (amber) WARNINGS entry is present; entry clears on first explicit user selection in that wsid (review #1).
  • Eviction of a stored mod_id that empties a radio-mode wsid falls back to §4 default-first; never leaves a radio-mode wsid with zero selections (review #2).
  • /api/resort request carries a client-side sequence number; responses older than the latest issued are discarded without state mutation (review #5).
  • /api/resort 5xx response leaves prior state intact and surfaces a transient retry-able warning (review #11).
  • Server drops unknown selected_mod_ids silently and logs at INFO; empty post-drop selection returns 400 (review #12).
  • colSpan in ModTable references a single COLUMN_COUNT constant - not a hardcoded integer (review #7).
  • storage event listener installed; cross-tab toggle of sortof.branch.selections syncs in-memory state and triggers exactly one /api/resort (review #8).

13. Test cases

  1. AuthenticZ canonical - wsid 2335368829, three rows, all incompatible_mods=[]. Expect: parent row ▾ 3 branches, default = all ticked, mode = checkboxes. Untick two → MODS_LINE reflects one. Reload → selection persists.
  2. 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.
  3. Mutually exclusive 2-branch - wsid where mod A's incompatible_mods lists mod B and vice versa. Expect: mode = radios, default = mod A only (first by parsed_at, mod_id).
  4. Persistence across reload - pick a non-default subset, reload page; confirm hydration from localStorage["sortof.branch.selections"] restores the selection on next sort.
  5. Stored mod_id no longer exists (checkbox mode) - manually inject a stored mod_id not in MOD_DB, reload. Expect: silent drop, no console error, default applies.
  6. 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.
  7. Zero-tick wsid - untick all branches in a multi-branch wsid. Expect: parent row stays in ModTable with ✓ 0 of N; no contribution to MODS_LINE / SORTED_ORDER / numeric counts.
  8. Radio-mode eviction-to-empty (review #6) - wsid in radio mode has stored selection [X]; X is removed from MOD_DB (e.g., upstream cache invalidation), reload. Expect: silent drop, then default-first applied, radio invariant preserved.
  9. 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/resort response.
  10. 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.
  11. /api/resort 5xx (review #11) - stub the endpoint to return 500; toggle a branch. Expect: prior state retained, transient red warning couldn't recompute sort - try again surfaced with retry control.
  12. Cross-tab sync (review #8) - open two tabs, toggle in tab A. Expect: tab B receives storage event and re-runs /api/resort with the new selection.
  13. Unknown selected_mod_id from server perspective (review #12) - POST /api/resort with selected_mod_ids=["modoptions","ghostMod"] where ghostMod isn't in mod_parsed. Expect: 200 with ghostMod silently absent from response; INFO log entry server-side. POST with all-ghost IDs → 400.