markdown formatting

This commit is contained in:
2026-08-09 16:16:45 +02:00
parent f4a6259cf3
commit 18dd3b7e07
21 changed files with 1630 additions and 893 deletions
+13 -13
View File
@@ -1,13 +1,13 @@
# 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.
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.
**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.
---
@@ -16,10 +16,10 @@ 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.
- 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.
This file does not grow unboundedly. If it does, that's a signal to either close
items or stop adding to it.
+93 -52
View File
@@ -2,7 +2,10 @@
## 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.
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:
@@ -14,17 +17,21 @@ The focused redesign is the delivery/merge/store seam:
## 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 |
| 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.
“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.
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
@@ -36,66 +43,93 @@ Running and Cancelled rows remain in the ticker and overlay, sorted after action
- `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`.
- 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.
- 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.
- 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.
- 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.
- 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`.
- 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.
- 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.
- 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.
- 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.”
- 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;
@@ -107,7 +141,8 @@ Running and Cancelled rows remain in the ticker and overlay, sorted after action
- Store unit tests:
- `Create + AddTime` succeeds in every input order.
- An expired receiver returns NeedHistory for orphan AddTime, then revives after receiving full history.
- 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.
@@ -119,7 +154,8 @@ Running and Cancelled rows remain in the ticker and overlay, sorted after action
- 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.
- NeedHistory, NeedHandshake, and lost acknowledgements trigger idempotent
handshake healing.
- Duplicate delivery is harmless.
- Frontend tests:
@@ -132,8 +168,10 @@ Running and Cancelled rows remain in the ticker and overlay, sorted after action
- 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.
- 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`
@@ -147,6 +185,9 @@ Running and Cancelled rows remain in the ticker and overlay, sorted after action
## 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.
- 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.
@@ -2,39 +2,125 @@
## 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.
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.
- **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."
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.
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`.
- **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.
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.
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.
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.
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.
@@ -1,30 +1,47 @@
# 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).
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.
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** |
| 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.
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
```
```text
+------------------------+
| Frontend (TS/Tauri) |
| Event Reducer & Hooks |
@@ -52,23 +69,38 @@ The plan outlined in [`CALL_TO_PLAY_FIXES_PLAN.md`](file:///pantheon/pfs/git/rus
```
### 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.
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.
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.
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.
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. **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
```
```text
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!)
@@ -76,21 +108,33 @@ The plan outlined in [`CALL_TO_PLAY_FIXES_PLAN.md`](file:///pantheon/pfs/git/rus
```
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.
- _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.
- _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.
- _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.
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.
@@ -2,45 +2,106 @@
## 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:
**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 |
| 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.
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.
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.
- **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").
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.
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.
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.
+3 -4
View File
@@ -68,9 +68,8 @@ up here. Structure:
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."
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.
_reasoning_ so future contributors can decide whether the trade-off still holds.
+4 -4
View File
@@ -31,8 +31,8 @@ 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.
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 }`.
@@ -49,8 +49,8 @@ and every manual invalidation call.
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:
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;
+9 -9
View File
@@ -30,8 +30,8 @@ documented trusted-LAN model without the unreliable IP equality test.
**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
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
@@ -39,8 +39,8 @@ 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
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.
@@ -74,12 +74,12 @@ 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.**
**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²).
`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
+15 -14
View File
@@ -4,22 +4,23 @@
### 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.
`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.
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
`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
@@ -46,8 +47,8 @@ The previous three findings have landed in code and tests:
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`.
- 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.
+15 -17
View File
@@ -28,8 +28,8 @@ I would:
- 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.
session" invariant. I would fix the semantics, not merely downgrade the repeated
warning.
### 2. Distinguish missing game directory from peer startup
@@ -39,22 +39,21 @@ 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.
- 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.
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.
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:
@@ -63,9 +62,9 @@ I would add a local-only Launch button when:
- 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.
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
@@ -79,8 +78,8 @@ The terminal receipt details are small but real:
I would:
- Freeze participant readiness at `terminalAt`, or render terminal
participants without countdown tags.
- 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
@@ -106,8 +105,8 @@ React test stack or waiting five real minutes:
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
- **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
@@ -126,4 +125,3 @@ The recommended finish scope is:
3. A Running-card Launch action.
4. Terminal-receipt polish and specification corrections.
5. Targeted replication tests.