156 lines
17 KiB
Markdown
156 lines
17 KiB
Markdown
# 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_id`s 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_id`s 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.
|
||
|
||
```json
|
||
{ "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_id`s, 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.
|
||
|
||
```json
|
||
{ "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_id`s 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_id`s 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_id`s 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.
|