improve PEER_AUTH_PLAN.md and re-organize files

This commit is contained in:
2026-08-09 16:08:01 +02:00
parent fe3c3c6520
commit f4a6259cf3
14 changed files with 36 additions and 71 deletions
+25
View File
@@ -0,0 +1,25 @@
# Backlog
Smells and small inconsistencies found during post-PLAN.md review. None of
these block merging — they are tracked here so they aren't forgotten and so
they don't reopen as "new findings" the next time someone reads the code.
**Rule of engagement:** items in this file get touched only when (a)
someone hits the symptom in practice, or (b) work in a nearby area makes
fixing the smell incidental. No batch refactor passes. No "while we're
here" cleanups that grow beyond the in-scope change.
---
No open backlog items.
## How items leave this file
- Closed by fix → delete the entry, mention it in the commit.
- Closed by decision ("we're not doing this") → delete the entry, no
commit message ceremony needed.
- Promoted to active work → move back to `FINDINGS.md` only when there's
a concrete plan to fix it now.
This file does not grow unboundedly. If it does, that's a signal to
either close items or stop adding to it.
@@ -0,0 +1,152 @@
# Finish Call to Plays replication seam
## Summary
Keep the existing architecture: immutable events, deterministic reduction, live delivery, and handshake history are appropriate for a LAN party. Do not replace it with owner-authoritative state, consensus, persistent storage, or cryptographic peer identities.
The focused redesign is the delivery/merge/store seam:
- Merge histories atomically.
- Acknowledge live deliveries.
- Preserve complete histories while users can still see a call.
- Make terminal outcomes understandable.
- Never report an action as accepted when it was immediately discarded.
## User-visible lifecycle
| State | Meaning | Visibility | Available actions |
|---|---|---:|---|
| Open / Ready | Call is still coordinating players | Until deadline | Existing participation, chat, creator controls |
| Times up | Deadline elapsed without a Start or Cancel | 5 minutes | Creator may Start, Add time, or Cancel; history remains complete |
| Running | Creator emitted Start; this is a successful outcome | 15 minutes after Start | Read-only card, roster, and complete chat |
| Cancelled | Creator emitted Cancel | 15 minutes after Cancel | Read-only card, roster, and complete chat |
| Retired | Display period ended | Hidden | None |
“Times up” and “Running” are meaningfully different: a timed-out call is unresolved and recoverable, while Running is a final success receipt. Deadline passage alone never means the game started.
Running and Cancelled rows remain in the ticker and overlay, sorted after actionable calls. They do not increase the top-bar badge. Their chat history remains readable, but all composers and controls are disabled.
## Interface and invariant changes
- Raise `PROTOCOL_VERSION` from 6 to 7; no compatibility path.
- Add `Response::CallToPlayAck` with outcomes equivalent to:
- `Applied`
- `Duplicate`
- `NeedHandshake`
- `NeedHistory`
- `Obsolete`
- `Rejected(reason)`
- Replace per-event insertion with one atomic `merge_batch(events, now)` operation returning retained UI events, duplicate/obsolete counts, and missing-history information.
- Change frontend nomination state to represent `running` and `cancelled`, with a `terminalAt` timestamp.
- Change `addTime` to receive the current effective deadline and calculate `max(now, deadline) + duration`.
- Preserve these invariants:
- Every visible call has its entire event and chat history.
- Handshake batches are evaluated as a whole, regardless of event order.
- Missing-root actions are not retained alone; they request history.
- Event IDs correspond only to retained events.
- Terminal tombstones prevent stale histories from resurrecting finished calls.
- Local acceptance is immediate user success; remote delivery is acknowledged and healed asynchronously.
## Commit sequence
1. `refactor(call-to-play): merge histories atomically`
- Validate and deduplicate the complete incoming batch before changing the store.
- Treat an existing ID with different contents as a conflict and reject the batch.
- Evaluate compaction once after all batch events are present, fixing the quadratic handshake path.
- Commit the candidate store only when capacity and validation succeed.
- Rebuild event IDs from retained events instead of preserving every historical ID.
- Return `NeedHistory` without storing an action when neither the store nor batch contains its Create event.
- Permit a full `Create + AddTime` history to revive a call atomically.
- Mark an event as applied only when it survives compaction; obsolete events are neither broadcast nor emitted to the UI.
- Keep the 4,096-event safety cap for unresolved histories, but always permit Start and Cancel. Recently terminal histories and compact tombstones must not prevent new active calls.
- Preserve full open/ready/Times-up histories; never evict individual chat or participant events.
2. `fix(call-to-play): acknowledge live replication`
- Turn the live Call to Play request into a request/response exchange returning `CallToPlayAck`.
- Remove source-IP-versus-advertised-IP equality checks.
- Under the selected trusted-LAN model, require:
- the envelope peer ID to exist in the known peer roster;
- every live events actor ID to match that envelope peer ID;
- local peer-core publication to continue stamping its own actor ID.
- Return `NeedHandshake` for unknown peers and `NeedHistory` for missing call roots.
- On transport failure, malformed response, `NeedHandshake`, or `NeedHistory`, perform one full Hello/HelloAck resync.
- Treat Applied and Duplicate as delivered, Obsolete as finished, and Rejected as a logged non-retriable error.
- Keep publication locally successful without waiting for every peer, so an offline machine cannot block a LAN-party action.
- Document that shared TLS plus stable peer IDs prevent accidental identity mixing but are not hostile-peer authentication.
3. `feat(call-to-play): retain terminal outcomes`
- Preserve complete Running and Cancelled histories in backend snapshots for 15 minutes so late joiners receive the card, roster, and chat.
- After 15 minutes, compact each terminal call to its Start or Cancel tombstone for the remainder of the peer session.
- Continue deleting unresolved Times-up histories after their separate five-minute recovery window, including their event IDs.
- Derive and render Running and Cancelled frontend states instead of immediately removing them.
- Show their ticker/card status, retain unknown-game degraded rendering, sort them last, and exclude them from the badge.
- Prune the frontends raw event map after the corresponding display window so a long GUI session does not accumulate invisible history.
- Update the feature specification and architecture documentation with these lifecycle and clock-skew assumptions.
4. `fix(call-to-play): extend from the current deadline`
- Calculate extensions as `max(Date.now(), nomination.deadline) + five minutes`.
- Preserve the existing behavior that an overdue call gets five minutes from now.
- Ensure extending a call that became Ready early adds time instead of shortening its remaining deadline.
- Keep terminal Running and Cancelled calls non-extendable.
5. `fix(call-to-play): explain peer startup state`
- Replace the game-folder advice shown while `actorId` is unavailable with: “Call to Play is still connecting to the LAN. Try again in a moment.”
- Do not mark transport unavailable for store-level errors.
- Surface distinct messages for:
- expired/obsolete call;
- full active history;
- peer startup;
- unexpected update failure.
## Test and acceptance plan
- Store unit tests:
- `Create + AddTime` succeeds in every input order.
- An expired receiver returns NeedHistory for orphan AddTime, then revives after receiving full history.
- IDs removed with expired histories do not block revival.
- Stale events against terminal tombstones remain obsolete.
- Accepted results contain only retained events.
- Invalid/conflicting batches leave the store unchanged.
- Batch merge compacts once rather than once per event.
- Start and Cancel remain possible at the active-history cap.
- Visible calls always retain every chat event.
- Transport tests:
- A known peer is accepted when transport and advertised IPs differ.
- Unknown peer IDs and mismatched actor IDs receive explicit acknowledgements.
- NeedHistory, NeedHandshake, and lost acknowledgements trigger idempotent handshake healing.
- Duplicate delivery is harmless.
- Frontend tests:
- Ready, Times up, Running, Cancelled, and retired transitions.
- Full terminal history remains for 15 minutes and then disappears.
- Terminal calls appear in ticker/overlay but not the badge.
- Terminal controls and chat composer are disabled.
- Both Add-time deadline cases.
- Correct startup and store-error messages.
- Peer CLI:
- Keep S48 as the active-call/full-history late-join acceptance test.
- Add S49 covering a terminal call whose roster and chat are reconstructed by a late joiner.
- Fix any snapshot waiting through the direct reply path rather than observing unrelated generations.
- Final verification:
- `just fmt`
- `just clippy`
- `just test`
- `just frontend-test`
- `just build`
- `just peer-cli-tests S48 S49`
- `git diff --check`
## Assumptions
- The LAN is cooperative; deliberate peer-ID impersonation is outside scope.
- Wall clocks are assumed reasonably close. Atomic history revival tolerates boundary skew but does not attempt clock synchronization.
- Call to Play state remains transient and disappears when the peer process/session ends.
- The existing `FABLE_5_FINDINGS.md` commit remains untouched; all implementation work is added as focused forward commits.
@@ -0,0 +1,40 @@
# Call to Play review — Fable 5 (xhigh)
## Verdict
The plan is faithfully implemented — all five commits match the planned sequence, scope, and invariants, and the full acceptance suite passes on my machine: workspace tests (189), frontend tests (26), clippy, fmt, `git diff --check`, frontend `tsc`, and the docker scenarios S48 and S49. The few deviations from the plan's letter are genuine improvements. I found no correctness bugs. There is one architectural edge case worth knowing about (self-healing, arguably by design) and one real UX friction point.
## a) Plan fidelity
Each plan bullet traced to code:
- **Atomic merge** — `merge_batch_at` (`call_to_play.rs:83`) validates the whole batch, dedups, detects ID conflicts, evaluates tombstones/rootedness against retained-plus-batch, compacts exactly once, and only then commits. Store-unchanged-on-error is tested for both invalid and conflicting batches. `Create + AddTime` revival is tested in both input orders.
- **Acknowledged delivery** — `PROTOCOL_VERSION` 7, `CallToPlayAck` with all six planned outcomes, request/response in `send_call_to_play_events`, and the resync matrix in `deliver_to_peer`/`delivery_resync_reason` exactly matches the plan: transport failure/malformed/NeedHandshake/NeedHistory → one Hello resync; Rejected → logged, non-retriable; Applied/Duplicate/Obsolete → done. The IP check is gone; identity is roster membership + envelope==actor, and ARCHITECTURE.md now states plainly that this is not hostile-peer authentication.
- **Terminal retention** — 15-minute full-history window, then tombstone-for-session, separate 5-minute recovery window for unresolved calls, frontend `running`/`cancelled` states with `terminalAt`, badge exclusion, sort-last, disabled composer/controls, raw-event pruning, and doc updates. S49 proves late-joiner reconstruction of a terminal call.
- **Deadline extension** — `extendDeadline = max(now, deadline) + 5min`, card passes `nomination.deadline`, tested for both the early-ready and overdue cases.
- **Startup message** — the Tauri command returns `Ok(false)` only for uninitialized peer core and `Err(store reason)` otherwise, and the hook maps these to the four distinct messages without marking transport unavailable for store errors. The connecting message self-clears once the 2-second snapshot poll succeeds.
**Deviations, all justified:**
1. The plan said "rebuild event IDs from retained events." The implementation went further and **deleted the separate ID set entirely** — dedup scans retained history directly. This makes the "IDs correspond only to retained events" invariant structurally impossible to violate rather than merely maintained. Better than the plan.
2. "Always permit Start and Cancel at the cap" is implemented as a generalization: the 4,096 cap counts only *unresolved* events (`unresolved_event_count`), so a terminal action inherently passes because it resolves the call, and settled histories/tombstones never consume active capacity. Cleaner than special-casing two action types, and both behaviors are tested.
3. A nice detail beyond the plan: a pre-terminal chat message arriving *after* the call went terminal still merges into the read-only display during the 15-minute window (the obsolete check compares against the terminal event's order key, not mere terminal existence). That's consistent with "every visible call has its entire history."
## b) Architecture
The design holds up well. `merge_batch` is now the single choke point for every mutation path — local publish, live delivery, and handshake all flow through one atomic validate → dedup → apply → compact operation. That is exactly the seam the findings pointed at, and collapsing findings 2, 3, and 5 into it was the right call. Convergence comes from a grow-only deduplicated event set plus deterministic compaction, with no consensus machinery — appropriate for a trusted LAN.
Two observations, neither blocking:
- **Rootless tombstones don't propagate.** The "missing-root actions are not retained alone" invariant applies to Start/Cancel too, so a peer that joins *after* a call's 15-minute window never stores the creator's tombstone (its handshake merge returns `NeedHistory`, which in the handshake path only logs). If a third peer that slept through the finish later hands that fresh peer the stale active history, the finished call briefly resurrects on the fresh peer until its next handshake with any tombstone-holder roots the call and applies the tombstone. It self-heals and requires an unusual sequence (long-deadline scheduled call + offline peer + fresh joiner), so I think the trade-off is fine — but be aware of it, and note the secondary symptom: the fresh peer logs a "handshake omitted roots" warning on every handshake with a tombstone-holder for the rest of the session. If that log noise bothers you, downgrading that specific case to debug would be cheap.
- **The backend `HistoryIndex` and the frontend reducer are parallel implementations** of the same semantics (creator authority, `(at, id)` ordering, earliest-terminal-wins, latest-extension-wins). I checked them against each other and they agree today, including the subtle cases (forged terminal by non-creator, extension ordering, pre-create actions). This duplication is inherent to having a Rust store and a TS presentation reducer, but it's the seam most likely to drift — any future rule change must land in both `call_to_play.rs` and `callToPlay.ts`.
Minor: `merge_batch` clones the full store per call, so a live event costs O(n) — irrelevant under the 4,096 cap, just don't raise the cap by 100× without revisiting.
## c) User perspective
The lifecycle is now genuinely intuitive. "Time's up" being unresolved-but-recoverable, and "Running" being an explicit success receipt that only the creator's Start can produce, is a real conceptual improvement — deadline passage never silently claims a game happened. The ticker ordering supports this: Time's up ranks *first* (it needs the creator's attention), terminal receipts sink to the bottom in muted colors and don't inflate the badge. "Add 5 more minutes" finally does what it says. The startup message no longer sends users hunting for a game-folder problem that doesn't exist.
One real friction point: **when a call starts, participants get no launch affordance.** The creator's "Start now" auto-launches locally, but everyone else's card flips to a read-only "X is running." note — at precisely the moment they all need to launch the game, they must close the overlay and find it in the library. The plan specified terminal cards as read-only, so this is faithful — but a "Launch" button on the Running card (for participants who have the game installed) would remove the most awkward step in the happy path. Worth a follow-up commit if you agree.
Two nits: `design/launcher/SPEC.md` still describes the ticker sort as "ready → starting-soon → the rest (TICKER_RANK = ready 0, soon 1…)" while the code ranks `expired` first — that mismatch predates this branch, but since the spec section was touched anyway it could have been corrected. And the ticker's "waiting to start" line for Ready calls doesn't say *who* everyone is waiting for, while the card note does name the creator — a tiny inconsistency, fine as is.
@@ -0,0 +1,96 @@
# Call to Play Code & Architecture Review Report
I have conducted a thorough review of the commits (`e141229` through `2c204ac`) on branch `calltoplay`, referencing [`FABLE_5_FINDINGS.md`](file:///pantheon/pfs/git/rust/pfs/lanspread/FABLE_5_FINDINGS.md) and [`CALL_TO_PLAY_FIXES_PLAN.md`](file:///pantheon/pfs/git/rust/pfs/lanspread/CALL_TO_PLAY_FIXES_PLAN.md).
---
## 1. Plan Implementation & Deviation Assessment
The plan outlined in [`CALL_TO_PLAY_FIXES_PLAN.md`](file:///pantheon/pfs/git/rust/pfs/lanspread/CALL_TO_PLAY_FIXES_PLAN.md) is **faithfully and elegantly implemented across all 5 code commits**, with zero regression to core invariants.
| Commit | Scope | Plan Requirements | Code Verification | Status |
|---|---|---|---|---|
| [`be7ad2e`](file:///pantheon/pfs/git/rust/pfs/lanspread/crates/lanspread-peer/src/call_to_play.rs) | Atomic Batch Merge & Store Semantics | - Transactional history merge<br>- Single-pass $O(N)$ compaction<br>- Conflicting ID batch rejection<br>- `NeedHistory` for missing roots<br>- Rebuild event IDs from retained events<br>- Capacity cap applies to unresolved history only | - `merge_batch_at` in [call_to_play.rs](file:///pantheon/pfs/git/rust/pfs/lanspread/crates/lanspread-peer/src/call_to_play.rs#L83)<br>- `deduplicate_batch`<br>- `unresolved_event_count`<br>- Tests cover revival & conflict behavior | **Faithful** |
| [`e5d70ae`](file:///pantheon/pfs/git/rust/pfs/lanspread/crates/lanspread-peer/src/services/stream.rs) | Live Replication & Acknowledgement | - Protocol v7 bump<br>- `CallToPlayAck` return outcomes<br>- Remove unreliable source-IP equality check<br>- Enforce envelope vs actor ID matching<br>- Async handshake resync on delivery failure | - Protocol updated in [`lanspread-proto`](file:///pantheon/pfs/git/rust/pfs/lanspread/crates/lanspread-proto/src/lib.rs)<br>- `handle_call_to_play_events` in [stream.rs](file:///pantheon/pfs/git/rust/pfs/lanspread/crates/lanspread-peer/src/services/stream.rs#L124)<br>- `delivery_resync_reason` in [call_to_play.rs](file:///pantheon/pfs/git/rust/pfs/lanspread/crates/lanspread-peer/src/call_to_play.rs#L472) | **Faithful** |
| [`9c34efa`](file:///pantheon/pfs/git/rust/pfs/lanspread/crates/lanspread-tauri-deno-ts/src/lib/callToPlay.ts) | Terminal Outcome Retention | - 15-minute terminal outcome display (`TERMINAL_RETENTION_MS`) for `Running` and `Cancelled`<br>- Post-15-min compaction to tombstones<br>- Frontend status reduction, sorting, and badge exclusion<br>- Peer CLI scenario S49 | - `compact_history` in [call_to_play.rs](file:///pantheon/pfs/git/rust/pfs/lanspread/crates/lanspread-peer/src/call_to_play.rs#L340)<br>- Reducer logic in [callToPlay.ts](file:///pantheon/pfs/git/rust/pfs/lanspread/crates/lanspread-tauri-deno-ts/src/lib/callToPlay.ts)<br>- Roster/chat retained | **Faithful** |
| [`8d3affe`](file:///pantheon/pfs/git/rust/pfs/lanspread/crates/lanspread-tauri-deno-ts/src/lib/callToPlay.ts) | Extend from Current Deadline | - `extendDeadline`: $\max(\text{now}, \text{currentDeadline}) + \text{duration}$ | - Implemented in `extendDeadline` in [callToPlay.ts](file:///pantheon/pfs/git/rust/pfs/lanspread/crates/lanspread-tauri-deno-ts/src/lib/callToPlay.ts#L12) | **Faithful** |
| [`2c204ac`](file:///pantheon/pfs/git/rust/pfs/lanspread/crates/lanspread-tauri-deno-ts/src/hooks/useCallToPlay.ts) | Startup & Error Guidance | - Replace missing folder prompt with LAN connecting state<br>- Map specific store errors (`Obsolete`, `NeedHistory`, `HistoryFull`) to human guidance | - Implemented in `useCallToPlay.ts` and `callToPlayPublishErrorMessage` | **Faithful** |
### Implementation Refinements Over the Initial Plan
1. **Tombstone Representation**: Rather than instantiating a separate tombstone data structure, `compact_history` retains **only** the `Start` or `Cancel` event (`event.id == terminal.event_id`) after the 15-minute terminal retention window expires. `terminal_tombstone_call_ids` uses unrooted terminal events to reject any incoming obsolete history. This is cleaner and more memory-efficient than allocating explicit tombstone markers.
2. **Unresolved History Cap (`unresolved_event_count`)**: The active event cap ($4,096$) is enforced strictly against *unresolved* calls (`Create` without `Start`/`Cancel`). As soon as a creator emits `Start` or `Cancel`, that call's events no longer count against the active cap. This guarantees a user can always settle (Start or Cancel) an open call even when the store is full.
---
## 2. Holistic Architecture Review
```
+------------------------+
| Frontend (TS/Tauri) |
| Event Reducer & Hooks |
+-----------+------------+
| publish_call_to_play / snapshot
v
+------------------------+
| CallToPlayStore |
| (Atomic Batch Merge) |
+-----------+------------+
|
+-------------------+-------------------+
| (Local immediate) | (Async broadcast)
v v
+-------------------+ +-------------------+
| Local UI Emitter | | Peer QUIC Stream |
+-------------------+ | (Protocol Ver 7) |
+---------+---------+
| CallToPlayEvents
v
+-------------------+
| CallToPlayAck |
| (Applied/NeedHist)|
+-------------------+
```
### Architectural Soundness
1. **Event-Sourced LAN Replication vs Server-Authoritative State**:
Maintaining an event-sourced replication model with deterministic reduction is optimal for LAN Spread. Decentralized peer-to-peer LAN parties lack guaranteed central servers. Using atomic batch merges ($O(N)$ compaction) completely eliminates the $O(N^2)$ quadratic slowdown of the previous per-event insertion model.
2. **Network Identity Model**:
Removing source-IP equality comparisons fixes a major real-world bug on multi-homed hardware (Ethernet + Wi-Fi / VPN / virtual bridges). Validating that `envelope peer_id` is present in the mDNS peer roster and verifying `event.actor_id == envelope peer_id` accurately matches the trusted-LAN threat model without making false cryptographic guarantees.
3. **Asynchronous Healing**:
Local updates succeed instantly for the local user without blocking on network delivery (`task_tracker.spawn(...)`). If a remote peer rejects an event with `NeedHistory` or `NeedHandshake`, an asynchronous full `Hello`/`HelloAck` resync is scheduled. This isolates local UI responsiveness from network transport delays.
4. **Lifecycle & Memory Management**:
The 3-tier lifecycle (`Open` $\rightarrow$ `Time's up` [5-min recovery] $\rightarrow$ `Running`/`Cancelled` [15-min display] $\rightarrow$ `Tombstone`) strikes the right balance between retaining full chat/roster history for late joiners and preventing unbounded memory growth.
---
## 3. User Experience (UX) Analysis
```
UX Flow Comparison (Add Time Action)
BEFORE: [10-min Call] -- (Filled at min 2) --> Click "+5 mins" --> Deadline set to (2+5) = 7 mins! (SHORTENED!)
AFTER: [10-min Call] -- (Filled at min 2) --> Click "+5 mins" --> Deadline set to max(2, 10)+5 = 15 mins! (EXTENDED!)
```
1. **Intuitive "+5 minutes" Extension**:
- *Previous behavior*: Setting deadline to `now + 5` inadvertently shortened calls that reached capacity early.
- *Current behavior*: `Math.max(now, currentDeadline) + 5` preserves existing remaining time when extending early, and correctly grants 5 new minutes to an overdue call.
2. **Startup & Connection Guidance**:
- *Previous behavior*: Attempting an action during startup raised misleading errors about missing game folders.
- *Current behavior*: Shows `"Call to Play is still connecting to the LAN. Try again in a moment."` while `actorId` is initializing, clearing automatically upon connection.
3. **Clear Terminal Receipts (`Running` and `Cancelled`)**:
- *Previous behavior*: Starting or canceling a call caused it to disappear or act erratically, hiding game chat.
- *Current behavior*: `Running` displays as a clear green success receipt card, and `Cancelled` displays as a read-only historical card. Roster and chat remain accessible for 15 minutes, sorted below active calls and excluded from badge counts.
---
## Conclusion & Recommendation
The commits are **clean, robust, and fully faithful to the findings and plan**. The architectural choices are sound for a LAN environment, and the UX is intuitive and frictionless.
No further code changes are needed; the implementation is ready for merge.
@@ -0,0 +1,46 @@
# Review: Call to Play fix series (`872692e..2c204ac`)
## a) Faithfulness to the plan — high, with only minor test-plan gaps
**Commit mapping is 1:1 with the planned sequence**, same titles, and every bullet lands:
| Plan | Implementation | Verdict |
|---|---|---|
| 1. Atomic merge | `merge_batch` validates + dedups the whole batch, conflicts reject without mutation (`self.events` only assigned at the end), one post-merge compaction, IDs derived from retained events, orphan actions → `missing_call_ids`, Create+AddTime revives in any order (tested both orders), `applied` = only what survives compaction, cap counts only unresolved events so Start/Cancel always settle | ✓ Faithful |
| 2. Acknowledged replication | `PROTOCOL_VERSION` 6→7, `CallToPlayAck` with exactly the six planned outcomes, IP check removed, roster + actor-matches-envelope checks, NeedHandshake/NeedHistory/transport/malformed → one Hello/HelloAck resync, Applied/Duplicate delivered, Obsolete finished, Rejected logged non-retriable, publication spawns deliveries into `task_tracker` and returns after local merge (verified: `join_all` is inside the spawned task, so an offline peer can't block the UI), ARCHITECTURE.md documents the trust model honestly | ✓ Faithful |
| 3. Terminal retention | 15-min full terminal histories in snapshots, then tombstone-only for the session; unresolved Time's-up evicted as a unit after 5 min; `running`/`cancelled` states with `terminalAt`; ticker/overlay rendering, sorted last, excluded from badge; unknown-game degraded rendering kept; frontend raw-map pruning; SPEC.md + ARCHITECTURE.md updated incl. clock-skew assumption; S49 added | ✓ Faithful |
| 4. Extend from current deadline | `max(now, deadline) + 5min`, overdue case preserved, terminal calls non-extendable (controls unrendered) | ✓ Faithful |
| 5. Startup message | Exact planned wording, store errors no longer mark transport unavailable, four distinct messages (startup / obsolete / full / unexpected), connecting message auto-clears once the peer is ready | ✓ Faithful |
All six Fable-5 findings are addressed, and every invariant in the plan's list verifiably holds in the final code. The "merge histories atomically" fix correctly treats findings 2+5 as one problem, as the findings demanded.
**Test-plan gaps (minor):**
1. **No end-to-end test of the triggered heal.** The plan's "NeedHistory, NeedHandshake, and lost acknowledgements trigger idempotent handshake healing" is only covered at the `delivery_resync_reason` decision level. S48/S49 prove handshake-based reconstruction, but nothing drives an orphan-AddTime → NeedHistory → resync → revival sequence. Understandable (needs 5-min waits or clock mocking), but it's a real gap against the plan's own list.
2. **"Terminal controls and chat composer are disabled"** has no automated test (frontend tests are lib-level only; there is no component-test infrastructure). Verified by inspection instead.
3. **"Fix snapshot waiting via the direct reply path"** turned out to be a no-op — the CLI's `call_to_play_events` already polls through the reply channel. Justified deviation.
**Nits:** `known_peer_id_accepts_live_events_without_transport_ip_matching` is somewhat vacuous (the function no longer takes `remote_addr`, so IP mismatch can't even be expressed) — harmless as intent documentation. `callToPlayPublishErrorMessage` substring-matches backend error strings — a brittle coupling, though the tests pin the current wording.
## b) Architecture — sound choices throughout
- **Derived event IDs + tombstones** is the elegant part: the ID set is bounded by construction, revival works, resurrection is blocked, and finding 2's "retained IDs correspond to retained events and deliberate terminal tombstones" is literally realized.
- **Fail-closed batch rejection** (invalid/conflict → store untouched) is the right default under the trusted-LAN model. The trade-off — one bad event voids an entire handshake heal — is practically unreachable: IDs are UUIDs and validation is deterministic and identical sender-side.
- **Retention symmetry is the quiet win:** backend compaction and frontend derivation use the same constants (5/15 min) keyed off *event timestamps*, not receipt times. All peers converge on identical visibility with zero extra protocol, and the S49 late-joiner case falls out naturally.
- **Ack semantics map 1:1 onto merge outcomes**, and the heal is idempotent (lost ack → resync → duplicate). Not rebroadcasting live events keeps it loop-free. The identity story is now honest: roster + actor match, documented as not-authentication.
- **Capacity on unresolved history only** fixes the "Start at cap" deadlock and keeps settled calls from pressuring new ones. Tombstones accumulate one small event per finished call per session — negligible and deliberate.
- The handshake receiver *logging* missing roots rather than re-requesting is correct — the handshake is itself the heal, and re-requesting would loop.
## c) User experience — a genuine improvement; lifecycle finally coherent
The old flow had two genuinely weird behaviors: a started call vanished after **3 seconds**, and a cancelled call vanished **instantly** — mid-conversation, for everyone. The new flow (Open/Ready → Time's up, recoverable → Running/Cancelled receipts for 15 min → retired) matches how a LAN party actually works: "who's playing what right now?" is answerable at a glance, receipts sort last, stay out of the badge, and chat remains readable. "Time's up" vs "Running" being distinct states (unresolved vs final receipt) is the right call — deadline passage never implies the game started. Add-time now does what its label says, the startup message no longer blames the wrong cause, and error messages are specific and actionable ("Start or cancel an active call, then try again").
**Residual friction, in decreasing order of importance:**
1. **First-run users see "still connecting… try again in a moment" forever.** The peer only starts via `update_game_directory`, so without a game folder there is no "moment" after which it connects. Finding 6 explicitly wanted folder guidance *reserved for a known missing-folder condition* — the plan narrowed that to just the connecting message and the implementation follows the plan, so this is faithful, but the finding's full intent isn't realized. The frontend already has `hasGameDirectory` in `useGameDirectory`; wiring it into the Call to Play error path would close this cheaply. This is the one place where deviating from the plan would have been justified.
2. **Terminal ticker rows render `MiniBubbles` with live "ready in Xm" countdown tags** for participants whose `readyAt` hadn't elapsed — a Running receipt with ticking countdowns looks slightly alive when it's meant to be a receipt.
3. **Terminal cards render empty roster slots up to maxPlayers** — on a Cancelled receipt, empty slots can read as "seats still open".
4. Pre-existing, out of scope: the badge counts Time's-up calls for everyone, though only the creator can act on them.
## Verdict
Approve. The plan is implemented faithfully and, where it matters (derived IDs, atomic merge, ack-driven healing, retention symmetry), the execution is as good as or better than the plan described. Architecture and UX are coherent. Before merging I'd only consider: (1) the missing-folder special case, since the finding called for it and the data is already available in the frontend, (2) the two cosmetic terminal-receipt nits, and (3) noting the untested heal loop as a known coverage gap — none of which are blockers.
+76
View File
@@ -0,0 +1,76 @@
# Clean code notes
Running notes on architectural smells in the codebase: things that work today
but are shaped wrong, and what a cleaner design would look like. Each entry
should explain the smell, why the current shape exists, and the warning sign
that says "now is the time to refactor."
---
## Resolved: local library and operation UI signals are split
**Context.** Commit `a9f9845` ("fix(peer): suppress duplicate local game
updates") added a `last_local_update_key` cache in `Ctx`, keyed on
`(revision, digest, active_operations)`. That worked, but it made scan emission
responsible for deduplicating operation-state changes that were actually owned
by command handlers.
### The root issue
`update_and_announce_games` already knows whether the library actually changed:
`LocalLibraryState::update_from_scan` returns `Option<LibraryDelta>`, where
`None` means "nothing changed." That fact gates peer `LibraryDelta`
announcements. Active operation status has a different source of truth:
`Ctx::active_operations`, mutated by operation start, handoff, end, liveness
cancellation, and guard cleanup.
The old `LocalGamesUpdated { games, active_operations }` event mixed those two
sources of truth. The dedup key was a symptom of that overload.
### Current shape
The peer runtime now emits two separate facts:
- `LocalLibraryChanged { games }` is emitted from scans when the local library
state changes. A real `SetGameDir` path change may force one local snapshot
for the UI even when the library digest matches the previous path, because
Tauri has already cleared local flags for the old path.
- `ActiveOperationsChanged { active_operations }` is emitted when the operation
table changes. Normal mutations go through `begin_operation`,
`transition_download_to_install`, and `end_operation`; liveness cancellation
and `OperationGuard` cleanup publish the same snapshot when they clear state.
Tauri is the join boundary. It stores the latest game DB and latest active
operation snapshot, then keeps emitting the existing frontend
`games-list-updated` payload. The frontend did not need to learn the peer
runtime's internal event split.
### Invariants to protect
Do not reintroduce a scan-level dedup cache for operation state. If a new path
mutates `Ctx::active_operations`, route it through the operation publisher or
explicitly document why it is not UI-visible. If a new local scan reason needs a
UI snapshot without a peer delta, model that as an explicit scan policy like the
path-change forced snapshot, not as cache invalidation.
---
## How to add to this file
When a code review uncovers a "this works but it's bolted on" pattern, write it
up here. Structure:
1. **Context** — what commit/PR introduced the pattern, one-paragraph summary.
2. **Root issue** — the underlying invariant the code is working around.
3. **Why the obvious fix doesn't work** — what constraints forced the current
shape.
4. **The tell** — concrete code shapes (scattered invalidations, repeated
special cases, dedup keys that re-derive existing facts) that signal the
smell.
5. **Clean shape** — what the code would look like without the constraint.
6. **Warning signs** — what observations in future work mean "do the
refactor now."
Keep entries narrative, not bulleted to death. The point is to preserve the
_reasoning_ so future contributors can decide whether the trade-off still
holds.
+70
View File
@@ -0,0 +1,70 @@
# Clean Code Plan 1: Split Local Library and Operation UI Signals
## Goal
Replace the `a9f9845` local-update dedup cache with explicit event semantics.
The peer runtime should report local library changes and active operation
changes as separate facts, while the Tauri layer keeps joining those facts into
the existing `games-list-updated` payload for the frontend.
## Architectural Picture
The current overloaded shape makes `LocalGamesUpdated` carry two independent
signals:
- local library contents, whose source of truth is `LocalLibraryState` and
`LocalLibraryState::update_from_scan`;
- active operation status, whose source of truth is `Ctx::active_operations`.
The clean boundary is:
- peer scanning emits a local-library event when the scanned library state
changes, with an explicit force policy for accepted path changes where the UI
needs a fresh snapshot but peers do not need a delta;
- operation-state mutation emits an operation snapshot when the mutation
happens;
- Tauri owns UI joining: it stores the latest catalog/local games and latest
operation snapshot, then emits `games-list-updated` for the frontend.
That keeps the frontend contract stable while removing the cross-cutting cache
and every manual invalidation call.
## Implementation Steps
1. Remove commit `a9f9845` from the local branch history before implementing
the replacement, so the final code is not built on the band-aid.
2. Replace `PeerEvent::LocalGamesUpdated { games, active_operations }` with:
- `PeerEvent::LocalLibraryChanged { games }`;
- `PeerEvent::ActiveOperationsChanged { active_operations }`.
3. Add one operation-snapshot publisher near the peer event helpers. All normal
operation mutations must go through helpers that mutate
`Ctx::active_operations` and then emit `ActiveOperationsChanged`.
4. Make `OperationGuard` publish an operation snapshot when it performs
exceptional cleanup on drop, so cancellation or aborted tasks do not leave UI
state stale.
5. Keep the existing scan behavior that freezes active game summaries while an
operation is running, but emit `LocalLibraryChanged` only when
`update_from_scan` returns a real delta or the scan was explicitly forced by
an accepted path change.
6. Update the Tauri event loop to reconcile `ActiveOperationsChanged`
independently, and call `emit_games_list` after both library and operation
state changes.
7. Update focused tests in peer handlers, local monitor, liveness, context guard,
and Tauri reconciliation to prove:
- unchanged settled scans do not emit local-library events;
- operation starts/transitions/ends emit authoritative snapshots;
- exceptional guard cleanup clears the operation snapshot;
- Tauri still emits the same `games-list-updated` UI payload.
8. Update `CLEAN_CODE.md`, `crates/lanspread-peer/ARCHITECTURE.md`, and
`crates/lanspread-peer/README.md` so the docs describe the new shape rather
than the dedup warning.
## Review Gates
- No `last_local_update_key`, `LocalUpdateKey`, or invalidate helper remains.
- No operation-state mutation that should be visible to the UI bypasses the
snapshot publisher.
- The peer event names reflect domain facts, not UI implementation details.
- Tauri remains the compatibility boundary for the frontend payload.
- Verification runs through `just fmt`, `just test`, `just clippy`, and
`git diff --check`.
+107
View File
@@ -0,0 +1,107 @@
# Fable 5 findings
Review of the issues Fable found after the Call to Play follow-up fixes. This
document records the assessment only; it is not an implementation plan.
## 1. IP-based actor verification can reject legitimate peers
**Assessment: real and must-fix.**
`handle_call_to_play_events` requires the QUIC connection's source IP to match
the peer's advertised listener IP. That assumption is unreliable for a
multi-homed LAN machine: routing may select Ethernet, Wi-Fi, a VPN, or a bridge
address different from the advertised address. The receiver silently drops the
event, while the one-way sender sees a successful write and does not invoke its
resync fallback.
The check is not strong authentication either. Two peers on one host share an
IP, and the project currently uses a shared application TLS certificate rather
than a cryptographic identity unique to each `peer_id`. A normal client still
stamps its own peer ID, so same-host impersonation requires a modified client,
but the IP comparison should not be presented as an authenticated identity
boundary.
A sound correction must either bind a peer ID to a connection through a
same-connection protocol exchange, return an application-level
acknowledgement/rejection that can trigger resync, or deliberately rely on the
documented trusted-LAN model without the unreliable IP equality test.
## 2. Evicted event IDs can prevent a revived call from healing
**Assessment: real and must-fix together with finding 5.**
Expiry removes call events from `events` but leaves their IDs in `event_ids`.
If one peer expires a call and later receives an orphan `AddTime`, a subsequent
handshake cannot restore the original `Create`: it is rejected forever as a
duplicate. The call can therefore be alive on its creator while remaining
invisible on the other peer. The ID set also grows without a bound for the
session.
Pruning expired IDs alone is not sufficient with the current `insert_all`
behavior. A handshake commonly supplies `Create` followed by later actions;
per-event compaction can expire and remove `Create` before the merge reaches
the extending `AddTime`. Healing requires atomic batch semantics: validate and
deduplicate the batch, merge it with retained events, then compact once using
the complete history.
Retained IDs should correspond to retained events and deliberate terminal
tombstones, not every event ever observed.
## 3. `insert` can accept an event that compaction immediately removes
**Assessment: correct, lower-severity correctness/semantics issue.**
An action targeting a call that expired more than five minutes ago can be
inserted, removed by compaction in the same operation, and still return
`Ok(true)`. It is then emitted to the local UI and broadcast to peers even
though it has no durable effect.
The presentation reducer normally hides the result, but the accepted signal is
misleading and the work is wasted. Store results should distinguish retained,
duplicate, rejected, and immediately obsolete events, and only retained events
should be emitted or broadcast.
## 4. "Add 5 more minutes" can shorten the deadline
**Assessment: real user-visible bug.**
The action currently sets the deadline to `now + 5 minutes`. If a roster fills
early while the call still has more than five minutes remaining, the button
shortens the call despite saying that it adds time.
The intended calculation is `max(now, current deadline) + 5 minutes`. That also
behaves naturally for an already expired call.
## 5. Handshake history merge is quadratic
**Assessment: correct, and part of the correctness fix for finding 2 rather
than merely a performance nit.**
`insert_all` calls `insert` for every incoming event, and each insertion rebuilds
several maps over the growing store while holding its write lock. Merging a
large handshake history is therefore O(n²).
Compacting once after an atomic batch merge removes that cost and is also what
allows `Create` plus a later `AddTime` to revive consistently. Findings 2 and 5
should be treated as one store-semantics problem.
## 6. The startup error points users at the wrong cause
**Assessment: correct UX issue.**
When `actorId` is still unavailable, the peer may simply be starting. Telling
the user to choose a game folder is misleading unless the application actually
knows that the folder is missing. The normal startup state should say that Call
to Play is connecting and ask the user to try again shortly; folder guidance
should be reserved for a known missing-folder condition.
## Priority
1. Replace the unreliable IP identity check or make delivery acknowledged.
2. Correct batch merge, compaction, and event-ID retention as one change.
3. Correct the deadline-extension calculation.
4. Stop reporting and broadcasting immediately obsolete events.
5. Correct the startup message.
The existing QUIC connection must not be described as carrying an authenticated
per-peer identity until the protocol actually establishes one.
+53
View File
@@ -0,0 +1,53 @@
# Findings
## Open
### Crash-during-download leaves orphan archive files
`crates/lanspread-peer/src/install/transaction.rs:329``recover_download_transients`
sweeps only `.version.ini.tmp` and `.version.ini.discarded` on startup. The new
cancel-cleanup (`download/storage.rs::discard_cancelled_download`) is only invoked
from the in-flight orchestrator, so a crash mid-download leaves partial `.eti`
archives in the game root. After restart the user sees a game that looks
half-downloaded with no way to clean it up except `RemoveDownloadedGame`. Closing
this would mean calling the same discard pass during recovery for any game root
whose intent is `None` and whose `version.ini` is absent.
Not blocking. The cancel-button fix is correct in its scope; this is the symmetric
crash-recovery case.
### `handleErrorEvent` still writes status fields directly
`crates/lanspread-tauri-deno-ts/src/hooks/useGames.ts:80-89` — the error
handler writes `install_status`, `status_message`, `status_level`, and
`download_progress` from a lifecycle event, which is the same "two sources of
truth" pattern that commit `5df82aa` ("fix(ui): derive operation status from
snapshots") removed everywhere else. That commit explicitly carved out error
messages as a preserved side effect, so this is a documented exception rather
than a regression — but if we want strict snapshot-is-truth, the error handler
should stop writing status fields and let the next snapshot reconcile the card,
keeping only the error message overlay (which the snapshot does not carry).
Not blocking. Captured here for a future cleanup pass.
## Claude Review Scope Triage
No out-of-scope code smells or issues were identified in Claude's review. All
four points were direct follow-up cleanup for the current protocol change and
were handled in code.
The previous three findings have landed in code and tests:
- `update_game` now uses `PeerCommand::FetchLatestFromPeers` to skip local
manifest serving and fetch fresh peer metadata. Covered by
`update_fetch_emits_fresh_manifest_from_latest_peer` and
`update_request_skips_local_manifest_even_when_download_exists`.
- Download-to-install handoff no longer relies on `OperationGuard::Drop` for
ordered state transitions. Covered by
`download_handoff_waits_for_readers_and_auto_installs` and the liveness
cancellation tests.
- Library index reads and writes are serialized by `LIBRARY_INDEX_LOCK`.
Covered by `concurrent_rescans_preserve_both_index_updates`.
Manual install/update/uninstall smoke testing is still a useful release check,
but there are no known blocking findings left in this file.
@@ -0,0 +1,129 @@
# Call to Play review findings evaluation
The implementation is fundamentally sound, but I would make four focused
follow-ups before calling it finished. Two address real behavioral gaps; two
complete the new lifecycle cleanly.
## Worth fixing before finish
### 1. Propagate rootless terminal tombstones
This is the most important finding.
After 15 minutes, a peer retains only the `Start` or `Cancel` event. A fresh
peer receiving that tombstone during handshake currently rejects it as missing
its `Create` root, and the handshake path merely logs the missing root.
That means the fresh peer has no protection against a stale peer later
resurrecting the finished call. It only self-heals if a tombstone-holder is
still online afterward.
I would:
- Add an explicit handshake merge policy that permits rootless `Start` and
`Cancel` events as non-visible tombstones.
- Keep local and live orphan actions returning `NeedHistory`.
- Never expose a rootless tombstone as a nomination in the UI.
- Verify that a later stale complete history is classified obsolete.
- Add store and handshake tests for this sequence.
This restores the intended "tombstone prevents resurrection for the rest of the
session" invariant. I would fix the semantics, not merely downgrade the
repeated warning.
### 2. Distinguish missing game directory from peer startup
Kimi is correct here. The peer is only started after a valid directory reaches
`update_game_directory`. Without one, "still connecting -- try again" never
becomes true.
I would:
- Model directory readiness as `checking | missing | ready`, rather than
passing only a boolean that conflates hydration with a known missing
directory.
- Pass that prerequisite state into `useCallToPlay`.
- Show folder guidance only for the confirmed `missing` state.
- Preserve the current connecting message for `checking` or
`ready-but-peer-starting`.
- Test both states and the transition after a valid directory is selected.
That finishes the original finding's full intent without returning to
misleading folder advice during normal startup.
### 3. Add a local Launch action to Running receipts
Fable's UX point is persuasive. Participants currently reach the key moment
and see only that the game is running.
I would add a local-only Launch button when:
- The call is `running`.
- The game is installed and launchable locally.
- No conflicting operation prevents launch.
This would not violate the read-only terminal invariant: launching the local
game does not mutate the replicated call. I would use a dedicated play
callback rather than the generic primary action, so a button labelled "Launch"
cannot unexpectedly initiate an install or update.
### 4. Make terminal receipts visually static and correct the spec
The terminal receipt details are small but real:
- `MiniBubbles` continue advancing pending-ready countdowns after a call is
Running or Cancelled.
- Terminal cards still draw empty seats.
- The specification still says "one row per active call" and documents the old
ticker rank, despite terminal rows and expired-first ordering.
I would:
- Freeze participant readiness at `terminalAt`, or render terminal
participants without countdown tags.
- Suppress empty roster slots on terminal cards.
- Change Ready ticker text to name the creator.
- Update the ticker specification to match the actual visible-call and ranking
behavior.
## Coverage to add alongside those fixes
I would close the acknowledgement-heal test gap, but without introducing a full
React test stack or waiting five real minutes:
- Exercise `NeedHistory` from a live orphan action.
- Apply the full history through the handshake merge path.
- Assert the receiver is revived exactly once.
- Cover the new rootless-tombstone handshake followed by stale-history
rejection.
- Rename the IP test so its name honestly describes what it proves; the current
helper no longer accepts a source address.
## Findings not worth pursuing now
- **Backend/frontend semantic duplication:** a real maintenance risk, but
eliminating it would require an architectural rewrite. Shared conformance
fixtures could be a later improvement.
- **Full-store cloning per merge:** acceptable under the 4,096 unresolved-event
cap.
- **Substring-matched frontend errors:** brittle, but currently pinned by
tests; a proper fix requires a typed peer-to-Tauri error contract and is
disproportionate for finishing this branch.
- **Missing component-test infrastructure:** inspection plus pure reducer tests
is adequate here; I would not add a UI test framework solely for these
controls.
- **Time's-up calls in the badge:** intentional. These calls remain unresolved
and visible, even for non-creators.
- **The direct-reply observation:** there is nothing to fix; the CLI already
uses the reply channel.
## Recommended finish scope
The recommended finish scope is:
1. Tombstone propagation.
2. Accurate directory/startup diagnosis.
3. A Running-card Launch action.
4. Terminal-receipt polish and specification corrections.
5. Targeted replication tests.