markdown formatting
This commit is contained in:
@@ -1,10 +1,9 @@
|
||||
# Implementation Decisions
|
||||
|
||||
- Added a `just test` recipe so unit tests can be run through the repository's
|
||||
required `just ...` command surface instead of invoking `cargo test`
|
||||
directly.
|
||||
- Renamed the frontend success event to `game-install-finished`; the old
|
||||
unpack name no longer matched the transactional install/update lifecycle.
|
||||
required `just ...` command surface instead of invoking `cargo test` directly.
|
||||
- Renamed the frontend success event to `game-install-finished`; the old unpack
|
||||
name no longer matched the transactional install/update lifecycle.
|
||||
- Implemented watcher rescans by reusing the app-state
|
||||
`local_library/index.json` cache and updating a single game entry in that
|
||||
index. This satisfies the per-ID optimized rescan requirement without adding a
|
||||
|
||||
+148
-150
@@ -5,10 +5,9 @@
|
||||
Revised implementation plan; not yet implemented.
|
||||
|
||||
This plan deliberately treats Lanspread as what it is: a desktop utility for
|
||||
friends and other attendees at a LAN party to discover each other, share a
|
||||
known game catalog at LAN speed, and coordinate a match. It is not an account
|
||||
system, a global untrusted file-sharing network, or a device-administration
|
||||
product.
|
||||
friends and other attendees at a LAN party to discover each other, share a known
|
||||
game catalog at LAN speed, and coordinate a match. It is not an account system,
|
||||
a global untrusted file-sharing network, or a device-administration product.
|
||||
|
||||
The normal user journey must remain:
|
||||
|
||||
@@ -18,9 +17,9 @@ The normal user journey must remain:
|
||||
4. Let Lanspread swarm from matching peers and verify the result itself.
|
||||
5. Use Call to Play while those people are present.
|
||||
|
||||
Security mechanisms in this plan are automatic. There are no key backup
|
||||
dialogs, trust ceremonies, fingerprint prompts, or per-device download
|
||||
permissions in the normal UI.
|
||||
Security mechanisms in this plan are automatic. There are no key backup dialogs,
|
||||
trust ceremonies, fingerprint prompts, or per-device download permissions in the
|
||||
normal UI.
|
||||
|
||||
The project still has one current wire version and no compatibility shims. The
|
||||
wire changes below are developed together and activated with one protocol bump,
|
||||
@@ -28,16 +27,16 @@ not three partially compatible protocol generations.
|
||||
|
||||
## 1. Product and architecture decisions
|
||||
|
||||
| Area | Decision | User-visible result |
|
||||
|---|---|---|
|
||||
| Filesystem safety | Validate the complete destination manifest before any mutation and confine it to one catalog game root. | A hostile peer cannot overwrite another game, `local/`, saves, or transaction state. |
|
||||
| Content authority | Ship BLAKE3 file and chunk hashes from the same bundled catalog authority as `game.db`. | Every eligible nearby peer is usable automatically; wrong bytes are rejected and retried elsewhere. |
|
||||
| Peer identity | Use one installation-local TLS key and derive `PeerId` from that TLS public key. | Identity works silently and survives ordinary restarts when possible; users do not manage it. |
|
||||
| Transport | Pin every outbound QUIC connection to the expected `PeerId`. | An address spoof or MITM cannot impersonate the peer selected as a source. |
|
||||
| Control messages | Use ordinary bounded protocol messages inside TLS. Treat unauthenticated inbound change notifications only as hints that trigger a pinned pull, and carry current revisions on the liveness ping that already runs so a lost hint self-heals. | No signed-envelope layer, nonce ledger, or message-signing overhead. |
|
||||
| Call to Play | Exchange only each peer's own session state by direct pinned pulls; do not relay third-party histories. | Calls are live LAN-party state and disappear naturally as their authors leave. |
|
||||
| Privacy | Provide one global Local network sharing switch. | Participation is easy to understand; no per-peer policy matrix. |
|
||||
| Protocol rollout | Make one cutover to the new current protocol. | Mixed versions are explained clearly, without maintaining legacy paths. |
|
||||
| Area | Decision | User-visible result |
|
||||
| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| Filesystem safety | Validate the complete destination manifest before any mutation and confine it to one catalog game root. | A hostile peer cannot overwrite another game, `local/`, saves, or transaction state. |
|
||||
| Content authority | Ship BLAKE3 file and chunk hashes from the same bundled catalog authority as `game.db`. | Every eligible nearby peer is usable automatically; wrong bytes are rejected and retried elsewhere. |
|
||||
| Peer identity | Use one installation-local TLS key and derive `PeerId` from that TLS public key. | Identity works silently and survives ordinary restarts when possible; users do not manage it. |
|
||||
| Transport | Pin every outbound QUIC connection to the expected `PeerId`. | An address spoof or MITM cannot impersonate the peer selected as a source. |
|
||||
| Control messages | Use ordinary bounded protocol messages inside TLS. Treat unauthenticated inbound change notifications only as hints that trigger a pinned pull, and carry current revisions on the liveness ping that already runs so a lost hint self-heals. | No signed-envelope layer, nonce ledger, or message-signing overhead. |
|
||||
| Call to Play | Exchange only each peer's own session state by direct pinned pulls; do not relay third-party histories. | Calls are live LAN-party state and disappear naturally as their authors leave. |
|
||||
| Privacy | Provide one global Local network sharing switch. | Participation is easy to understand; no per-peer policy matrix. |
|
||||
| Protocol rollout | Make one cutover to the new current protocol. | Mixed versions are explained clearly, without maintaining legacy paths. |
|
||||
|
||||
The resulting data flow is intentionally small:
|
||||
|
||||
@@ -58,31 +57,31 @@ pinned liveness ping -> responder's own current revisions
|
||||
## 2. Threat model and guarantees
|
||||
|
||||
Assume a hostile device can join the same LAN, advertise arbitrary mDNS data,
|
||||
send arbitrary protocol messages, occupy reused IP addresses, and run a
|
||||
modified Lanspread build. The attacker does not control the victim's OS, the
|
||||
installed Lanspread application, or its bundled catalog files.
|
||||
send arbitrary protocol messages, occupy reused IP addresses, and run a modified
|
||||
Lanspread build. The attacker does not control the victim's OS, the installed
|
||||
Lanspread application, or its bundled catalog files.
|
||||
|
||||
After this plan:
|
||||
|
||||
- a remote description cannot make Lanspread create, truncate, or delete a
|
||||
path outside the requested catalog game's download-owned area;
|
||||
- a selected responder must prove possession of the TLS private key whose
|
||||
public key derives the expected `PeerId`;
|
||||
- a remote description cannot make Lanspread create, truncate, or delete a path
|
||||
outside the requested catalog game's download-owned area;
|
||||
- a selected responder must prove possession of the TLS private key whose public
|
||||
key derives the expected `PeerId`;
|
||||
- mDNS, IP addresses, display names, and inbound notification bodies never
|
||||
become identity authority by themselves;
|
||||
- a source cannot make a download commit bytes that differ from the hashes in
|
||||
the victim's bundled catalog, even if that source is the only peer present;
|
||||
- corrupt sources are removed from the current transfer automatically rather
|
||||
than presented to the user as a trust decision; and
|
||||
- one peer cannot publish Call-to-Play actions as another peer or mutate
|
||||
another peer's author-owned state.
|
||||
- one peer cannot publish Call-to-Play actions as another peer or mutate another
|
||||
peer's author-owned state.
|
||||
|
||||
The following are explicit non-goals:
|
||||
|
||||
- A `PeerId` does not prove a human name. Display names remain friendly labels.
|
||||
- The installation key is not a user account and has no promised continuity
|
||||
across OS reinstall, application-data deletion, or copying the application
|
||||
to another computer.
|
||||
across OS reinstall, application-data deletion, or copying the application to
|
||||
another computer.
|
||||
- Content hashes prove that bytes match the bundled catalog. They do not prove
|
||||
that the catalog publisher's game is benign, licensed, or malware-free.
|
||||
- A hash advertised by the same peer that sends the bytes is not trusted. The
|
||||
@@ -113,11 +112,11 @@ Validation is for the complete list and fails without any mutation. It must:
|
||||
|
||||
- require a known catalog `game_id` and resolve every destination relative to
|
||||
exactly `<games_folder>/<game_id>`;
|
||||
- use one canonical forward-slash relative-path form and reject empty,
|
||||
absolute, drive-qualified, UNC, NUL, `.`, `..`, mixed-separator, and
|
||||
non-normalized paths;
|
||||
- reject duplicate paths, file/directory conflicts, and platform aliases such
|
||||
as Windows case, trailing-dot/space, device-name, and alternate-data-stream
|
||||
- use one canonical forward-slash relative-path form and reject empty, absolute,
|
||||
drive-qualified, UNC, NUL, `.`, `..`, mixed-separator, and non-normalized
|
||||
paths;
|
||||
- reject duplicate paths, file/directory conflicts, and platform aliases such as
|
||||
Windows case, trailing-dot/space, device-name, and alternate-data-stream
|
||||
collisions;
|
||||
- reject `local/`, `.local.*`, download/install intent state, legacy state,
|
||||
scratch sentinels, and every other path owned by installation or recovery,
|
||||
@@ -133,17 +132,17 @@ The Tauri command supplies only the selected `game_id`. The peer core chooses
|
||||
the complete authoritative plan. A UI-echoed file list is never authority.
|
||||
|
||||
After a successful complete transfer, remove download-owned files absent from
|
||||
the authoritative manifest before committing `version.ini`. Preserve
|
||||
`local/`, install staging/backup state, and user-owned files in all success,
|
||||
failure, cancellation, and recovery paths.
|
||||
the authoritative manifest before committing `version.ini`. Preserve `local/`,
|
||||
install staging/backup state, and user-owned files in all success, failure,
|
||||
cancellation, and recovery paths.
|
||||
|
||||
For the current protocol, this validator safely contains the existing remote
|
||||
descriptions. A narrow protocol-7 adapter requires and removes exactly one
|
||||
matching leading `game_id/` component (and discards only the current exact
|
||||
redundant game-root directory entry) before constructing root-relative paths;
|
||||
it rejects a missing/different/doubled prefix. After the protocol cutover, the
|
||||
same validated type is constructed directly from the bundled content manifest
|
||||
and remote descriptions cease to define local paths at all.
|
||||
redundant game-root directory entry) before constructing root-relative paths; it
|
||||
rejects a missing/different/doubled prefix. After the protocol cutover, the same
|
||||
validated type is constructed directly from the bundled content manifest and
|
||||
remote descriptions cease to define local paths at all.
|
||||
|
||||
Required proof includes hostile descriptors placed after valid descriptors,
|
||||
cross-game paths, both requested and other-game `local/` sentinels, reserved
|
||||
@@ -153,10 +152,10 @@ mutation.
|
||||
|
||||
### 3.2 Make the bundled catalog the content authority
|
||||
|
||||
`game.db` is already the application's authority for game identity and
|
||||
version. Add reproducibly generated per-game companion manifest artifacts,
|
||||
located at `manifests/<game_id>.json` (loaded on-demand when downloading or serving a game),
|
||||
and package them with both the desktop application and peer-CLI fixtures.
|
||||
`game.db` is already the application's authority for game identity and version.
|
||||
Add reproducibly generated per-game companion manifest artifacts, located at
|
||||
`manifests/<game_id>.json` (loaded on-demand when downloading or serving a
|
||||
game), and package them with both the desktop application and peer-CLI fixtures.
|
||||
|
||||
For each supported `(game_id, game_version)`, the manifest artifact contains:
|
||||
|
||||
@@ -176,11 +175,11 @@ CatalogContentManifest {
|
||||
}
|
||||
```
|
||||
|
||||
Entries are sorted by canonical path. `content_id` is BLAKE3 over a
|
||||
versioned, length-delimited encoding of all preceding manifest fields and
|
||||
hashes, excluding the `content_id` field itself; it is not the current
|
||||
noncryptographic `u64 manifest_hash`. Golden tests freeze that encoding. The
|
||||
ordinary chunk size matches Lanspread's 128 MiB transfer chunk.
|
||||
Entries are sorted by canonical path. `content_id` is BLAKE3 over a versioned,
|
||||
length-delimited encoding of all preceding manifest fields and hashes, excluding
|
||||
the `content_id` field itself; it is not the current noncryptographic
|
||||
`u64 manifest_hash`. Golden tests freeze that encoding. The ordinary chunk size
|
||||
matches Lanspread's 128 MiB transfer chunk.
|
||||
|
||||
The catalog publishing workflow must generate these per-game manifests from the
|
||||
canonical game packages, verify them by rereading the packages, and fail the
|
||||
@@ -193,22 +192,22 @@ games.
|
||||
Peers advertise only that they can serve a catalog `content_id`. A peer counts
|
||||
as a source for the local catalog game only when its advertised ID exactly
|
||||
matches the receiver's expected ID. The receiver builds paths, sizes, chunks,
|
||||
and expected hashes entirely from its local catalog manifest. This replaces remote
|
||||
manifest selection and majority-by-file-size consensus.
|
||||
and expected hashes entirely from its local catalog manifest. This replaces
|
||||
remote manifest selection and majority-by-file-size consensus.
|
||||
|
||||
For ordinary downloads:
|
||||
|
||||
1. Select every currently reachable peer advertising the expected
|
||||
`content_id`; there is no approval prompt.
|
||||
1. Select every currently reachable peer advertising the expected `content_id`;
|
||||
there is no approval prompt.
|
||||
2. Carry `PeerEndpoint { peer_id, addr }` and `content_id` through planning,
|
||||
swarming, progress, and retry.
|
||||
3. The sender serves only an exact catalog file/range for the requested
|
||||
`(game_id, content_id)` and applies the same canonical/reserved-path policy
|
||||
before opening a local file. A caller-supplied path can never expose
|
||||
`local/` or another local file.
|
||||
4. Hash each chunk with BLAKE3 while receiving it and compare it before marking that chunk
|
||||
complete. Exact length, offset coverage, and the catalog file shape are also
|
||||
mandatory.
|
||||
before opening a local file. A caller-supplied path can never expose `local/`
|
||||
or another local file.
|
||||
4. Hash each chunk with BLAKE3 while receiving it and compare it before marking
|
||||
that chunk complete. Exact length, offset coverage, and the catalog file
|
||||
shape are also mandatory.
|
||||
5. On mismatch, invalidate that write, quarantine that `(PeerId, content_id)`
|
||||
for the current runtime/transfer, and retry the chunk from another matching
|
||||
peer. Do not create durable “trust” state.
|
||||
@@ -216,42 +215,43 @@ For ordinary downloads:
|
||||
successfully. Failure leaves the game non-downloadable/non-installable and
|
||||
preserves `local/`.
|
||||
|
||||
No background disk-scanning or pre-hashing of existing files is required. Chunks are verified strictly as they stream in during an active transfer.
|
||||
No background disk-scanning or pre-hashing of existing files is required. Chunks
|
||||
are verified strictly as they stream in during an active transfer.
|
||||
|
||||
Streamed install needs a catalog-owned extracted-file manifest because the
|
||||
sender controls both today's RAR CRC32 metadata and extracted bytes. The
|
||||
receiver accepts exactly the expected path set, sizes, and BLAKE3 values in
|
||||
isolated staging, then applies the documented local account/language rewrite
|
||||
and promotes the transaction. CRC32 may remain as an early corruption check,
|
||||
but it is not the security boundary. A game without a verified extracted
|
||||
manifest does not offer Stream Install; there is no unverified fallback or
|
||||
warning-through button.
|
||||
isolated staging, then applies the documented local account/language rewrite and
|
||||
promotes the transaction. CRC32 may remain as an early corruption check, but it
|
||||
is not the security boundary. A game without a verified extracted manifest does
|
||||
not offer Stream Install; there is no unverified fallback or warning-through
|
||||
button.
|
||||
|
||||
Hashing is performed in the existing streaming I/O path. The acceptance gate
|
||||
measures end-to-end throughput on the standard LAN workload and avoids a
|
||||
second full read when complete chunk coverage already proves the file bytes.
|
||||
measures end-to-end throughput on the standard LAN workload and avoids a second
|
||||
full read when complete chunk coverage already proves the file bytes.
|
||||
|
||||
### 3.3 Use a simple installation-local TLS identity
|
||||
|
||||
The identity exists to bind a live peer and its changing address to TLS. It is
|
||||
not exposed as a user credential.
|
||||
|
||||
- Generate one self-issued TLS certificate/key pair in Tauri's
|
||||
`app_data_dir()` and store it in one versioned application file with
|
||||
restrictive permissions where the platform supports them.
|
||||
- Generate one self-issued TLS certificate/key pair in Tauri's `app_data_dir()`
|
||||
and store it in one versioned application file with restrictive permissions
|
||||
where the platform supports them.
|
||||
- Prefer Ed25519 if the selected s2n-quic rustls provider supports the complete
|
||||
responder-verification path. Otherwise use one supported P-256 TLS key. Do not
|
||||
add a second signing identity or a custom certificate-extension binding.
|
||||
- Define `PeerId` as lowercase unpadded base32 of
|
||||
`BLAKE3(canonical DER SubjectPublicKeyInfo)` from the actual TLS key. The
|
||||
same key is therefore both the identity and the TLS proof-of-possession key.
|
||||
- Validate on load that the private key, certificate SPKI, and derived ID
|
||||
agree. Never log private material.
|
||||
- A valid file is reused. A missing or corrupt file is regenerated
|
||||
automatically (quarantining corrupt bytes best-effort) and produces at most
|
||||
a diagnostic log entry. If persistence is unavailable, use a fresh in-memory
|
||||
identity for that run and show a non-blocking diagnostic; LAN functionality
|
||||
should not become a repair wizard.
|
||||
`BLAKE3(canonical DER SubjectPublicKeyInfo)` from the actual TLS key. The same
|
||||
key is therefore both the identity and the TLS proof-of-possession key.
|
||||
- Validate on load that the private key, certificate SPKI, and derived ID agree.
|
||||
Never log private material.
|
||||
- A valid file is reused. A missing or corrupt file is regenerated automatically
|
||||
(quarantining corrupt bytes best-effort) and produces at most a diagnostic log
|
||||
entry. If persistence is unavailable, use a fresh in-memory identity for that
|
||||
run and show a non-blocking diagnostic; LAN functionality should not become a
|
||||
repair wizard.
|
||||
- The peer CLI may accept an explicit deterministic identity file/seed for
|
||||
repeatable tests. It does not probe keyrings or share a default container
|
||||
identity accidentally.
|
||||
@@ -278,11 +278,11 @@ Call-to-Play refresh, metadata/content requests, chunk plans, retries, streamed
|
||||
install, healing, liveness, and direct peer-CLI operations. Delete
|
||||
address-derived IDs, unique-IP identity fallbacks, and address-only connects.
|
||||
|
||||
mDNS supplies bounded candidates containing `(peer_id, addr, protocol,
|
||||
revision hints)`. It may cause a dial, but it never directly creates or
|
||||
updates authenticated peer/library/Call-to-Play state. A candidate becomes a
|
||||
peer only after a successful outgoing TLS connection to its advertised address
|
||||
proves the expected `PeerId`.
|
||||
mDNS supplies bounded candidates containing
|
||||
`(peer_id, addr, protocol, revision hints)`. It may cause a dial, but it never
|
||||
directly creates or updates authenticated peer/library/Call-to-Play state. A
|
||||
candidate becomes a peer only after a successful outgoing TLS connection to its
|
||||
advertised address proves the expected `PeerId`.
|
||||
|
||||
Use TLS 1.3 with a self-issued certificate. The custom client verifier must:
|
||||
|
||||
@@ -291,17 +291,17 @@ Use TLS 1.3 with a self-issued certificate. The custom client verifier must:
|
||||
endpoint's expected ID; and
|
||||
3. perform real TLS 1.3 CertificateVerify validation under the presented key.
|
||||
|
||||
The load-bearing negative test presents peer A's certificate/SPKI with peer
|
||||
B's private key and requires the handshake to fail. Also reject a different
|
||||
valid peer at a reused address. Use one version-bound ALPN, disable 0-RTT, and
|
||||
start without TLS session resumption so every short-lived connection performs
|
||||
the simple full proof.
|
||||
The load-bearing negative test presents peer A's certificate/SPKI with peer B's
|
||||
private key and requires the handshake to fail. Also reject a different valid
|
||||
peer at a reused address. Use one version-bound ALPN, disable 0-RTT, and start
|
||||
without TLS session resumption so every short-lived connection performs the
|
||||
simple full proof.
|
||||
|
||||
The protocol is deliberately responder-authenticated rather than wrapping
|
||||
every message in a signature:
|
||||
The protocol is deliberately responder-authenticated rather than wrapping every
|
||||
message in a signature:
|
||||
|
||||
- Requests that read public library/content state may be made by any LAN
|
||||
client while Local network sharing is enabled.
|
||||
- Requests that read public library/content state may be made by any LAN client
|
||||
while Local network sharing is enabled.
|
||||
- A response is authoritative only to the initiator that connected using the
|
||||
expected `PeerEndpoint`; the TLS channel supplies integrity and request/
|
||||
response correlation.
|
||||
@@ -311,9 +311,9 @@ every message in a signature:
|
||||
For a known claimed ID they schedule one coalesced, rate-limited pull from
|
||||
that ID's already known endpoint. Their payload never merges directly. Hints
|
||||
for unknown IDs are ignored and mDNS remains the discovery path.
|
||||
- `Hello` becomes a pull-oriented exchange: the initiator sends no
|
||||
authoritative identity or replicated state, and the pinned responder returns
|
||||
its own current snapshot.
|
||||
- `Hello` becomes a pull-oriented exchange: the initiator sends no authoritative
|
||||
identity or replicated state, and the pinned responder returns its own current
|
||||
snapshot.
|
||||
|
||||
This extra pull is one small LAN round trip and removes general signed
|
||||
envelopes, canonical opaque payloads, nonce caches, replay semantics, inbound
|
||||
@@ -346,11 +346,11 @@ alone, which it could already achieve by changing nothing.
|
||||
|
||||
An unproven address collision never evicts an authenticated peer. If a pinned
|
||||
dial later proves that a different ID now owns the same address, atomically
|
||||
replace address ownership and retire the old record only if it still names
|
||||
that address/generation. A same-ID address move is likewise committed only
|
||||
after pinning the new endpoint. Pings are also pinned, and a late ping result
|
||||
may update/remove only the same endpoint generation it probed so it cannot
|
||||
delete a peer that has already moved or reconnected.
|
||||
replace address ownership and retire the old record only if it still names that
|
||||
address/generation. A same-ID address move is likewise committed only after
|
||||
pinning the new endpoint. Pings are also pinned, and a late ping result may
|
||||
update/remove only the same endpoint generation it probed so it cannot delete a
|
||||
peer that has already moved or reconnected.
|
||||
|
||||
Remove `Goodbye`. It is unnecessary for correctness and an unauthenticated
|
||||
removal hint is unsafe. mDNS expiry plus responder-pinned liveness handles
|
||||
@@ -392,18 +392,18 @@ Authority rules remain simple:
|
||||
- `Create`, `Start`, `Cancel`, and `AddTime` are effective only when the pinned
|
||||
author equals `CallId.creator`.
|
||||
- RSVP, ready/leave, and chat actions are attributed to the pinned author. They
|
||||
become effective only while the referenced creator root is directly
|
||||
present; an author slice pulled before its creator is retained within its
|
||||
ordinary bound but remains hidden until that creator's direct pull arrives.
|
||||
- A snapshot contains only events authored by its responder. Third-party
|
||||
events are rejected rather than relayed.
|
||||
become effective only while the referenced creator root is directly present;
|
||||
an author slice pulled before its creator is retained within its ordinary
|
||||
bound but remains hidden until that creator's direct pull arrives.
|
||||
- A snapshot contains only events authored by its responder. Third-party events
|
||||
are rejected rather than relayed.
|
||||
- Display names never grant authority.
|
||||
|
||||
A newly arriving peer discovers and pulls directly from every live peer, so it
|
||||
reconstructs calls from the people still present. If an author's peer goes
|
||||
away, remove that author's slice. If the creator goes away, the call disappears
|
||||
from the derived view. A participant who leaves naturally drops out. This is
|
||||
the intended session model, not data loss.
|
||||
reconstructs calls from the people still present. If an author's peer goes away,
|
||||
remove that author's slice. If the creator goes away, the call disappears from
|
||||
the derived view. A participant who leaves naturally drops out. This is the
|
||||
intended session model, not data loss.
|
||||
|
||||
Keep the useful human-scale timers: active calls expire, unresolved expired
|
||||
calls may remain visible for five minutes, and Start/Cancel results may remain
|
||||
@@ -412,13 +412,12 @@ snapshot. There are no session-long tombstones, rootless terminal records,
|
||||
three-day history horizons, verification caches, or permanent anti-resurrection
|
||||
state because no third party can replay an old author's history as authority.
|
||||
|
||||
Retain straightforward schema and resource limits: bounded strings/chat,
|
||||
bounded events and encoded bytes per author, bounded total live peers, and a
|
||||
named control-frame maximum. Validate one author's snapshot off to the side and
|
||||
accept or reject it as a unit; a bad/oversized peer cannot consume another
|
||||
author's slice or the local author's capacity. Exact limits are set from the
|
||||
existing three-peer and stress fixtures, not from an internet-scale adversary
|
||||
model.
|
||||
Retain straightforward schema and resource limits: bounded strings/chat, bounded
|
||||
events and encoded bytes per author, bounded total live peers, and a named
|
||||
control-frame maximum. Validate one author's snapshot off to the side and accept
|
||||
or reject it as a unit; a bad/oversized peer cannot consume another author's
|
||||
slice or the local author's capacity. Exact limits are set from the existing
|
||||
three-peer and stress fixtures, not from an internet-scale adversary model.
|
||||
|
||||
A malicious creator can show inconsistent versions of its own noncritical call
|
||||
to different peers. This plan accepts that limit rather than adding signatures,
|
||||
@@ -428,8 +427,8 @@ gossip, consensus, or permanent storage to a party invitation feature.
|
||||
|
||||
Add one visible `Local network sharing` setting, on by default for this
|
||||
LAN-sharing application. When off, stop mDNS advertisement/discovery, the QUIC
|
||||
listener, outbound refresh, and serving. The setting is durable and its state
|
||||
is obvious in the main UI/settings.
|
||||
listener, outbound refresh, and serving. The setting is durable and its state is
|
||||
obvious in the main UI/settings.
|
||||
|
||||
Do not add per-peer source prompts. Every peer with the locally expected
|
||||
`content_id` is an eligible swarm source; verification is automatic.
|
||||
@@ -441,10 +440,10 @@ Trusted, key-changed, backup, repair, or fingerprint-confirmation workflows.
|
||||
User-facing exceptional states are concrete:
|
||||
|
||||
- `Verifying downloaded chunks` while newly received content is checked;
|
||||
- `A source sent invalid data; retrying another nearby peer` when recovery is
|
||||
in progress;
|
||||
- `No nearby peer could provide the verified catalog version` after all
|
||||
matching sources fail;
|
||||
- `A source sent invalid data; retrying another nearby peer` when recovery is in
|
||||
progress;
|
||||
- `No nearby peer could provide the verified catalog version` after all matching
|
||||
sources fail;
|
||||
- `Nearby devices are running a different Lanspread version` when mDNS sees an
|
||||
incompatible protocol; and
|
||||
- a non-blocking networking diagnostic if the installation identity cannot be
|
||||
@@ -484,18 +483,18 @@ version mismatch rather than failing silently.
|
||||
Keep the change inside existing crates unless implementation pressure proves a
|
||||
real reusable boundary; a new identity crate is not required by the design.
|
||||
|
||||
| Area | Responsibility |
|
||||
|---|---|
|
||||
| `lanspread-db` / `lanspread-compat` | Catalog content-manifest types and loading beside `game.db`. |
|
||||
| `lanspread-proto` | `PeerId`, `PeerEndpoint`, `content_id`, pull snapshots, change hints, author-owned Call-to-Play wire types, and the one protocol version. No crypto or storage logic. |
|
||||
| `lanspread-peer::identity` | Simple key/certificate load-or-generate, SPKI-derived ID, and test identity injection. |
|
||||
| `lanspread-peer::network` | Per-endpoint rustls client config, full responder verification, ALPN, and no address-only connect. |
|
||||
| discovery/handshake/liveness | Candidate-only mDNS, pinned pulls, hint coalescing, revision reconciliation on ping, endpoint generations, and version-mismatch reporting. |
|
||||
| `peer_db` | Authenticated endpoint/state records and exact `content_id` source lookup. |
|
||||
| download/storage/stream install | Validated catalog plan, hash-as-received, source quarantine/retry, sentinel commit, and protected staging. |
|
||||
| `call_to_play` | Local author slice, per-peer replacement snapshots, simple authority checks, timers, and bounds. |
|
||||
| Tauri/frontend | Global sharing switch, verification/progress failures, incompatible-version notice, and replacement of the full derived Call-to-Play view. |
|
||||
| peer CLI | Distinct deterministic identities, hostile TLS/content modes, and zero-prompt multi-peer scenarios. |
|
||||
| Area | Responsibility |
|
||||
| ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `lanspread-db` / `lanspread-compat` | Catalog content-manifest types and loading beside `game.db`. |
|
||||
| `lanspread-proto` | `PeerId`, `PeerEndpoint`, `content_id`, pull snapshots, change hints, author-owned Call-to-Play wire types, and the one protocol version. No crypto or storage logic. |
|
||||
| `lanspread-peer::identity` | Simple key/certificate load-or-generate, SPKI-derived ID, and test identity injection. |
|
||||
| `lanspread-peer::network` | Per-endpoint rustls client config, full responder verification, ALPN, and no address-only connect. |
|
||||
| discovery/handshake/liveness | Candidate-only mDNS, pinned pulls, hint coalescing, revision reconciliation on ping, endpoint generations, and version-mismatch reporting. |
|
||||
| `peer_db` | Authenticated endpoint/state records and exact `content_id` source lookup. |
|
||||
| download/storage/stream install | Validated catalog plan, hash-as-received, source quarantine/retry, sentinel commit, and protected staging. |
|
||||
| `call_to_play` | Local author slice, per-peer replacement snapshots, simple authority checks, timers, and bounds. |
|
||||
| Tauri/frontend | Global sharing switch, verification/progress failures, incompatible-version notice, and replacement of the full derived Call-to-Play view. |
|
||||
| peer CLI | Distinct deterministic identities, hostile TLS/content modes, and zero-prompt multi-peer scenarios. |
|
||||
|
||||
## 6. Implementation phases and gates
|
||||
|
||||
@@ -521,8 +520,8 @@ reported as Windows proof.
|
||||
Add the reproducible content-manifest generator and fixture manifests. Freeze
|
||||
the versioned manifest/content-ID encoding with golden tests. Extend local
|
||||
catalog state, build download plans only from that state, implement streaming
|
||||
BLAKE3 checks and source quarantine, and implement verified extracted
|
||||
manifests for Stream Install.
|
||||
BLAKE3 checks and source quarantine, and implement verified extracted manifests
|
||||
for Stream Install.
|
||||
|
||||
Do not claim completion from test fixtures alone: production catalog packages
|
||||
must have independently generated manifests, and the release/build path must
|
||||
@@ -542,10 +541,10 @@ generation-conditional. No trust database or identity UI is introduced.
|
||||
|
||||
### Phase 4 — make the single wire cutover
|
||||
|
||||
Bump the current protocol once and activate all coupled wire behavior from
|
||||
§4: pinned transport, catalog `content_id`, catalog-driven downloads, pull-only
|
||||
library synchronization, bounded invalidation hints, author-owned
|
||||
Call-to-Play snapshots, and no `Goodbye`.
|
||||
Bump the current protocol once and activate all coupled wire behavior from §4:
|
||||
pinned transport, catalog `content_id`, catalog-driven downloads, pull-only
|
||||
library synchronization, bounded invalidation hints, author-owned Call-to-Play
|
||||
snapshots, and no `Goodbye`.
|
||||
|
||||
This phase is not complete until:
|
||||
|
||||
@@ -576,11 +575,11 @@ phase; do not leave the shared-certificate or relayed-event description behind.
|
||||
|
||||
### Phase 5 — finish the small user-facing surface and audit
|
||||
|
||||
Add the global sharing switch and the concrete progress/error states from
|
||||
§3.6. Run a first-run test with an empty app-data directory, a normal restart,
|
||||
a corrupt identity file, and unwritable identity persistence; none may produce
|
||||
a key-management workflow or prevent the ephemeral fallback from participating
|
||||
for that run.
|
||||
Add the global sharing switch and the concrete progress/error states from §3.6.
|
||||
Run a first-run test with an empty app-data directory, a normal restart, a
|
||||
corrupt identity file, and unwritable identity persistence; none may produce a
|
||||
key-management workflow or prevent the ephemeral fallback from participating for
|
||||
that run.
|
||||
|
||||
Run all standard checks, the complete peer-CLI suite, fresh three-peer manual
|
||||
scenarios, production builds/bundles on supported platforms, and a final audit
|
||||
@@ -600,8 +599,8 @@ for:
|
||||
|
||||
## 7. Success criteria
|
||||
|
||||
The plan is complete when the following statement is true from a user's point
|
||||
of view:
|
||||
The plan is complete when the following statement is true from a user's point of
|
||||
view:
|
||||
|
||||
> I opened Lanspread at a LAN party, immediately saw the people and games
|
||||
> nearby, downloaded from all matching peers without approving devices, and
|
||||
@@ -610,6 +609,5 @@ of view:
|
||||
|
||||
From the implementation point of view, that experience rests on only three
|
||||
security boundaries: confined local paths, catalog-owned content hashes, and
|
||||
responder-pinned TLS. Call to Play deliberately reuses the pinned-pull model
|
||||
and remains ephemeral instead of becoming a second distributed security
|
||||
protocol.
|
||||
responder-pinned TLS. Call to Play deliberately reuses the pinned-pull model and
|
||||
remains ephemeral instead of becoming a second distributed security protocol.
|
||||
|
||||
@@ -6,56 +6,56 @@ for deterministic local runs; mDNS/macvlan remains an environment smoke path.
|
||||
|
||||
## Scenario Matrix
|
||||
|
||||
| ID | Scenario | Setup | Expected result |
|
||||
| --- | --- | --- | --- |
|
||||
| S1 | Startup scan | Start one peer with `fixture-alpha`. | Peer emits `local-peer-ready` and `local-library-changed`; catalog fixture games are `downloaded=true`, `installed=false`, `availability=Ready`. |
|
||||
| S2 | Direct connect handshake | Start alpha and bravo, send alpha `connect` to bravo's ready address. | Both peers record one remote peer, no self-peer entry appears, and each peer receives the other's library. |
|
||||
| S3 | Remote aggregation | Empty client connects to alpha and bravo. | `list-games` shows remote-only games once; shared `ggoo` has `peer_count=2`, unique games have `peer_count=1`. |
|
||||
| S4 | Single-source download, no install | Empty client connected to bravo downloads `bfbc2` with `install=false`. | Client emits `got-game-files`, `download-begin`, `download-finished`, then local `bfbc2` is `downloaded=true`, `installed=false`; root files exist and `local/` does not. |
|
||||
| S5 | Auto-install download | Empty client connected to bravo downloads `cnctw` with default install. | Download finishes, install begins and finishes, and local `cnctw` is `downloaded=true`, `installed=true` with `local/fixture-payload.txt`. |
|
||||
| S6 | Manual install and uninstall | After S4, client sends `install bfbc2`, then `uninstall bfbc2`. | Install marks `bfbc2` installed and creates `local/`; uninstall removes `local/` while preserving downloaded root files. |
|
||||
| S7 | Duplicate-source majority download | Empty client connects to alpha and bravo, then downloads shared `ggoo`. | Metadata from both peers validates by majority/plurality, download completes once, and installed state matches the install flag. |
|
||||
| S8 | Ambiguous metadata rejection | Two peers advertise the same game/version with conflicting file sizes. | Download fails with a `download-failed` event; no committed `version.ini` is left for the target game. |
|
||||
| S9 | Missing game | Client asks for a game none of its peers can serve. | CLI reports a deterministic command failure and emits `no-peers-have-game`; no local files are created. |
|
||||
| S10 | Shutdown and goodbye cleanup | Alpha and bravo are connected, then bravo shuts down. | Alpha receives peer loss/removal and remote games from bravo disappear. |
|
||||
| S11 | Same identity reconnect | Bravo restarts with the same state dir (the OS assigns an ephemeral listener port that usually, but not necessarily, differs), then alpha connects again. | Alpha has exactly one bravo peer entry reusing the same peer ID, not a duplicate identity, at whatever address bravo now advertises. |
|
||||
| S12 | Transfer serving gates | A peer has a non-catalog, missing-sentinel, active-operation, or `local/` path request. | The serving peer declines metadata/data; covered by unit tests where timing is too small for a stable CLI race test. |
|
||||
| S13 | Exact transferred-file equality | Repeat small and large downloads, then compare every transferred regular file against its source with SHA-256 manifests. | Source and receiver manifests match exactly for each transferred file; no extra or missing files appear in the downloaded game root. |
|
||||
| S14 | Large multi-peer chunked download | A source advertises a synthetic catalog game whose `.eti` is a sparse file of `4 * CHUNK_SIZE` (four 128 MiB chunks). A second peer downloads it, then a third peer downloads it from both peers. | The third peer's downloaded files match the source by SHA-256; `download-chunk-finished` shows the `.eti` split across exactly both peers, all four chunks accounted for, and the per-peer byte totals balanced within one `CHUNK_SIZE` (a fair 2+2 split; a 3+1 imbalance would trip the check). |
|
||||
| S15 | Catalog-version skew | Three peers advertise the same catalog game ID. Peers A and B have stale `version.ini` values; peer C has the catalog's expected version. An empty client connects to all three and downloads the game with `install=false`. | `list-games` shows one row for the game with `peer_count=1` and the catalog `eti_game_version`. The `got-game-files` descriptor set and transfer source are peer C only; no chunks come from A or B. The receiver's `version.ini` and SHA-256 manifest match C exactly. |
|
||||
| S16 | Catalog-version fanout with stale peers present | Peer A has a stale version of a game. Peers B and C both advertise the catalog version with matching manifests; the `.eti` is inflated to `2 * CHUNK_SIZE` so it can fan out. | The aggregated row counts only catalog-version ready peers. The `.eti` chunks split across exactly B and C; peer A is not listed as downloadable and contributes no manifest vote or file chunks. |
|
||||
| S17 | Catalog-version conflict rejection | Peer A has a stale version. Peers B and C both advertise the catalog version, but their file sizes conflict. | Validation considers only the catalog-version peers, so A cannot rescue the majority. The download fails with `download-failed`, and no committed target `version.ini` remains. |
|
||||
| S18 | Mid-download source drop with redundancy | Client downloads a large shared multi-chunk game (sparse `4 * CHUNK_SIZE`, so both peers are assigned `.eti` chunks) from two ready peers, then one source is killed right after the download has begun. | The download survives the source kill: it finishes, no `download-failed` is emitted over the whole download window, every byte is delivered (chunk totals sum to the file size) with the survivor serving part of it, and the receiver's files match the source by diff or SHA-256. (Retry-onto-survivor is the mechanism that makes this possible, exercised when the kill interrupts an unfinished chunk, but it is not asserted because the kill timing cannot be forced; the per-source split is likewise not asserted.) |
|
||||
| S19 | Mid-download sole-source drop | Client downloads a large multi-chunk game (sparse `4 * CHUNK_SIZE`) from one source, then that source is force-killed immediately after `download-begin`. An individual chunk may complete before the kill lands, but the full multi-chunk download cannot, so the failure is deterministic on a fast LAN. | The download emits a terminal failure (`download-failed`, or `download-peers-gone` when the sole source vanishes) and no `download-finished`; no committed target `version.ini` remains; any partial payload is not advertised as ready; active operation state clears so a retry is possible. |
|
||||
| S20 | Receiver write failure | Client downloads a large game into a constrained `/games` filesystem. | The download fails deterministically, no committed `version.ini` is advertised, and active operation state clears so the peer can retry later. |
|
||||
| S21 | Add-game propagation | Two connected peers are running; one peer gains a new catalog game root through a completed download or an external drop. | The other peer receives a library update without reconnecting, and `list-games` shows the new remote game under the existing peer. |
|
||||
| S22 | Remove-game propagation | Two connected peers are running; one peer loses a previously advertised game root. | The other peer receives a library update without dropping the peer, and `list-games` no longer shows that remote game. |
|
||||
| S23 | Version bump propagation | Two connected peers are running; one peer's ready game root starts with a stale `version.ini`, then changes to the catalog version. | The other peer receives a library update without reconnecting; the stale row is absent before the change, then the catalog-version game appears as downloadable. |
|
||||
| S24 | Two clients pull from one source | Two empty clients connect to the same source and download the same large game concurrently. | Both downloads finish, both receivers match the source by diff or SHA-256, and the source remains responsive. |
|
||||
| S25 | One client downloads two games concurrently | One client connected to a source issues two different `download` commands without waiting for the first to finish. | Both operations may run in parallel; both eventually finish, each game reaches the requested install state, and each transferred root matches its source. |
|
||||
| S26 | Same-game duplicate download rejection | A client starts downloading a game, then issues a second `download` command for the same game while the first operation is active. | The second request is rejected deterministically as an operation-in-progress condition; the first download is not corrupted and still reaches its documented final state. |
|
||||
| S27 | Self-connect rejection | A peer sends `connect` to its own advertised listener address. | The CLI command fails cleanly (CLI-level guard), no self-peer entry is created, and the peer remains responsive. The protocol-level guard (a hello whose `peer_id` equals the local id is acknowledged but never recorded) is covered by the `handshake::tests::inbound_hello_from_self_is_ignored` unit test, which the CLI string-compare never reaches. |
|
||||
| S28 | Address change without identity change | A known peer is rediscovered with the same peer ID and a different listener address while its library is still known. | The peer record updates in place to the new address, the existing library stays attached to that peer ID, and no duplicate peer entry appears. This is covered with a deterministic unit-level check until the CLI can rebind a live listener without restart. |
|
||||
| S29 | Empty-library peer participates | A peer with no games connects into the mesh. | Other peers list it as a peer with zero games; it can receive a download, advertise the new game without restart, and become a source. |
|
||||
| S30 | 5+ peer mesh aggregation | Five peers advertise partially overlapping catalog games with a mix of unique and shared catalog-version games; a sixth client connects to all five. | The client shows one row per game ID, correct catalog-version ready-source `peer_count`, catalog `eti_game_version`, no duplicates, and no self entries. |
|
||||
| S31 | Bootstrapped peer becomes source in same session | An empty client downloads a game from a source, the original source shuts down, then a fresh third peer downloads the same game from the bootstrapped client. | The third peer's files match the original source by diff or SHA-256, proving downloaded files become servable without restart. |
|
||||
| S32 | Reinstall after uninstall | A downloaded game is installed, uninstalled, then installed again without another download. | `local/` is recreated from preserved root files, no transfer events occur during reinstall, and the game returns to `installed=true`. |
|
||||
| S33 | Install after external root mutation | A downloaded game root is externally mutated before `install` is issued. | The CLI fixture installer installs from the current root bytes. The resulting `local/fixture-payload.txt` must match the mutated archive bytes exactly. |
|
||||
| S34 | Many-small-files game without `.eti` | A catalog game root contains `version.ini` plus many small regular files and no archive. | Download with `install=false` transfers every file, chunk events are coherent for small files, and source/receiver manifests match exactly. |
|
||||
| S35 | Unknown game ID from remote peer | A remote peer advertises a game ID that is not in the receiver's catalog. | The receiver does not list the unknown game as downloadable, download attempts fail deterministically, and no local files are created. |
|
||||
| S36 | Catalog singleton beats stale majority | Five peers advertise one game; one peer has the catalog version and four peers have stale versions. | `list-games` reports `peer_count=1` and the catalog `eti_game_version`; all descriptors and chunks come from the singleton catalog-version peer, while stale peers remain hidden and contribute zero bytes. |
|
||||
| S37 | Single-source download throughput | A source peer advertises a temporary catalog game with one sparse `2 GiB` `.eti`; an empty client downloads it with `install=false`. | The client emits `download-finished` with throughput measurements (`bytes`, `duration_ms`, `mib_per_s`, `mbit_per_s`), and the downloaded archive size matches the source. |
|
||||
| S38 | First-play launch-setting stamping | `fixture-persona/css` ships a real RAR `.eti` whose tree buries a CRLF `SmartSteamEmu.ini` with a stub `PersonaName` line under `engine/bin/win64/steam_settings/`, plus a stub `account_name.txt` and `language.txt` under `profiles/local/`. A peer installs `css` (with `--unrar`), then sends `play css` with a username and language, then `play css` again. | After install the marker `games/css/launch_settings_applied` is absent and the stub files are intact under `local/`. The first `play` returns `already_applied=false` with `account_name_written`, `language_written`, and `persona_name_written` all true; the deep `SmartSteamEmu.ini` `PersonaName` value becomes the username with its `\r\n` ending and sibling lines preserved, `account_name.txt` becomes the username, `language.txt` becomes the passed language, and the marker now exists. A second `play` returns `already_applied=true`, rewrites nothing, and leaves the files untouched even if their values were reset externally. |
|
||||
| S39 | Streamed install without keeping archive payload | Empty client connects to `fixture-bravo`, then sends `stream-install cnctw`. The source has real RAR `.eti` payload entries under `bin/` and `data/`; the receiver uses the container-bundled `unrar` stream provider. | Client emits `download-begin`, streamed `download-chunk-finished`, `download-finished`, and `install-finished` (the install-start transition is observable via `active-operations-changed`; there is no separate `install-begin` event). Local `cnctw` is `downloaded=false`, `installed=true`, `availability=LocalOnly`; root `version.ini` and `.eti` are absent; `local/bin/cnctw-payload.bin` and `local/data/cnctw-assets.dat` match `unrar p` output by SHA-256; the source reports no active outbound transfer for `cnctw` after completion. |
|
||||
| S40 | Streamed install receiver is not a peer source | After S39, a third peer connects only to the streamed-install receiver. | The third peer may see the receiver's local-only summary in peer snapshots, but `list-games` remote aggregation does not expose `cnctw` as downloadable, `peer_count` remains zero/absent, and attempting `download cnctw` fails with no local files created. |
|
||||
| S41 | Solid archive streamed install | Empty client connects to a peer serving `fixture-solid/cnctw`, whose `.eti` is a real solid RAR archive. The receiver uses the container-bundled `unrar` stream provider. | The fixture is verified as solid with `unrar lt`; streamed install finishes with `downloaded=false`, `installed=true`, `availability=LocalOnly`; root archive and `version.ini` are absent; streamed byte count equals the extracted solid entries; local payload SHA-256 hashes match `unrar p` output. |
|
||||
| S42 | Streamed install whole-stream retry | Empty client connects to two peers serving the same catalog-version `cnctw`: one broken source whose `--unrar` path is missing, followed by one good source. | The broken source sorts before the good source in retry order, contributes zero chunks, and the good source completes a fresh whole-stream attempt. The final state is local-only installed, no root archive/sentinel, no `.local.installing`, byte count matches the extracted entries, and payload hashes match the good source. |
|
||||
| S43 | Already-installed streamed install rejection | A client first stream-installs `cnctw`, then attempts `stream-install cnctw` again. | The second request emits `download-failed`, does not emit a new success event, leaves the existing local-only install intact, and clears active operations. |
|
||||
| S44 | Corrupt archive streamed install rollback | A source advertises catalog-version `cnctw`, but its root `.eti` is replaced with invalid bytes before the client requests `stream-install cnctw`. | The stream emits `download-failed`, does not emit download/install success, clears active operations, and leaves no `local/`, `.local.installing`, root archive, or root `version.ini` on the receiver. |
|
||||
| S45 | Sender disconnect during streamed install | A source serves large catalog-version `alienswarm`; after the client receives the first streamed chunk, the source container is killed. | The operation reaches a terminal failure/peers-gone event, emits no download/install success, clears active operations, and rolls back local/staging state. |
|
||||
| S46 | Receiver cancel during streamed install | A client starts streaming large catalog-version `alienswarm`, receives the first chunk, then sends `cancel-download alienswarm`. | The receiver cancels without emitting download/install success or a user-visible download failure, clears active operations, and rolls back local/staging state. |
|
||||
| S47 | Multi-archive streamed install order | A source serves `fixture-multi/cnctw` with two root `.eti` archives named to require sorted processing. | Streamed chunk paths arrive in root archive sort order, both payloads install under `local/`, the receiver is local-only installed, and no root archives or sentinel are committed. |
|
||||
| S48 | Call to Play replication and late join | Alice and Bob connect; Alice publishes a call, then Bob publishes an RSVP and chat message. Charlie joins afterward and handshakes with Alice. | Alice and Bob receive the live events, while Charlie reconstructs the same three-event history during handshake with no duplicate event IDs. |
|
||||
| ID | Scenario | Setup | Expected result |
|
||||
| --- | ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| S1 | Startup scan | Start one peer with `fixture-alpha`. | Peer emits `local-peer-ready` and `local-library-changed`; catalog fixture games are `downloaded=true`, `installed=false`, `availability=Ready`. |
|
||||
| S2 | Direct connect handshake | Start alpha and bravo, send alpha `connect` to bravo's ready address. | Both peers record one remote peer, no self-peer entry appears, and each peer receives the other's library. |
|
||||
| S3 | Remote aggregation | Empty client connects to alpha and bravo. | `list-games` shows remote-only games once; shared `ggoo` has `peer_count=2`, unique games have `peer_count=1`. |
|
||||
| S4 | Single-source download, no install | Empty client connected to bravo downloads `bfbc2` with `install=false`. | Client emits `got-game-files`, `download-begin`, `download-finished`, then local `bfbc2` is `downloaded=true`, `installed=false`; root files exist and `local/` does not. |
|
||||
| S5 | Auto-install download | Empty client connected to bravo downloads `cnctw` with default install. | Download finishes, install begins and finishes, and local `cnctw` is `downloaded=true`, `installed=true` with `local/fixture-payload.txt`. |
|
||||
| S6 | Manual install and uninstall | After S4, client sends `install bfbc2`, then `uninstall bfbc2`. | Install marks `bfbc2` installed and creates `local/`; uninstall removes `local/` while preserving downloaded root files. |
|
||||
| S7 | Duplicate-source majority download | Empty client connects to alpha and bravo, then downloads shared `ggoo`. | Metadata from both peers validates by majority/plurality, download completes once, and installed state matches the install flag. |
|
||||
| S8 | Ambiguous metadata rejection | Two peers advertise the same game/version with conflicting file sizes. | Download fails with a `download-failed` event; no committed `version.ini` is left for the target game. |
|
||||
| S9 | Missing game | Client asks for a game none of its peers can serve. | CLI reports a deterministic command failure and emits `no-peers-have-game`; no local files are created. |
|
||||
| S10 | Shutdown and goodbye cleanup | Alpha and bravo are connected, then bravo shuts down. | Alpha receives peer loss/removal and remote games from bravo disappear. |
|
||||
| S11 | Same identity reconnect | Bravo restarts with the same state dir (the OS assigns an ephemeral listener port that usually, but not necessarily, differs), then alpha connects again. | Alpha has exactly one bravo peer entry reusing the same peer ID, not a duplicate identity, at whatever address bravo now advertises. |
|
||||
| S12 | Transfer serving gates | A peer has a non-catalog, missing-sentinel, active-operation, or `local/` path request. | The serving peer declines metadata/data; covered by unit tests where timing is too small for a stable CLI race test. |
|
||||
| S13 | Exact transferred-file equality | Repeat small and large downloads, then compare every transferred regular file against its source with SHA-256 manifests. | Source and receiver manifests match exactly for each transferred file; no extra or missing files appear in the downloaded game root. |
|
||||
| S14 | Large multi-peer chunked download | A source advertises a synthetic catalog game whose `.eti` is a sparse file of `4 * CHUNK_SIZE` (four 128 MiB chunks). A second peer downloads it, then a third peer downloads it from both peers. | The third peer's downloaded files match the source by SHA-256; `download-chunk-finished` shows the `.eti` split across exactly both peers, all four chunks accounted for, and the per-peer byte totals balanced within one `CHUNK_SIZE` (a fair 2+2 split; a 3+1 imbalance would trip the check). |
|
||||
| S15 | Catalog-version skew | Three peers advertise the same catalog game ID. Peers A and B have stale `version.ini` values; peer C has the catalog's expected version. An empty client connects to all three and downloads the game with `install=false`. | `list-games` shows one row for the game with `peer_count=1` and the catalog `eti_game_version`. The `got-game-files` descriptor set and transfer source are peer C only; no chunks come from A or B. The receiver's `version.ini` and SHA-256 manifest match C exactly. |
|
||||
| S16 | Catalog-version fanout with stale peers present | Peer A has a stale version of a game. Peers B and C both advertise the catalog version with matching manifests; the `.eti` is inflated to `2 * CHUNK_SIZE` so it can fan out. | The aggregated row counts only catalog-version ready peers. The `.eti` chunks split across exactly B and C; peer A is not listed as downloadable and contributes no manifest vote or file chunks. |
|
||||
| S17 | Catalog-version conflict rejection | Peer A has a stale version. Peers B and C both advertise the catalog version, but their file sizes conflict. | Validation considers only the catalog-version peers, so A cannot rescue the majority. The download fails with `download-failed`, and no committed target `version.ini` remains. |
|
||||
| S18 | Mid-download source drop with redundancy | Client downloads a large shared multi-chunk game (sparse `4 * CHUNK_SIZE`, so both peers are assigned `.eti` chunks) from two ready peers, then one source is killed right after the download has begun. | The download survives the source kill: it finishes, no `download-failed` is emitted over the whole download window, every byte is delivered (chunk totals sum to the file size) with the survivor serving part of it, and the receiver's files match the source by diff or SHA-256. (Retry-onto-survivor is the mechanism that makes this possible, exercised when the kill interrupts an unfinished chunk, but it is not asserted because the kill timing cannot be forced; the per-source split is likewise not asserted.) |
|
||||
| S19 | Mid-download sole-source drop | Client downloads a large multi-chunk game (sparse `4 * CHUNK_SIZE`) from one source, then that source is force-killed immediately after `download-begin`. An individual chunk may complete before the kill lands, but the full multi-chunk download cannot, so the failure is deterministic on a fast LAN. | The download emits a terminal failure (`download-failed`, or `download-peers-gone` when the sole source vanishes) and no `download-finished`; no committed target `version.ini` remains; any partial payload is not advertised as ready; active operation state clears so a retry is possible. |
|
||||
| S20 | Receiver write failure | Client downloads a large game into a constrained `/games` filesystem. | The download fails deterministically, no committed `version.ini` is advertised, and active operation state clears so the peer can retry later. |
|
||||
| S21 | Add-game propagation | Two connected peers are running; one peer gains a new catalog game root through a completed download or an external drop. | The other peer receives a library update without reconnecting, and `list-games` shows the new remote game under the existing peer. |
|
||||
| S22 | Remove-game propagation | Two connected peers are running; one peer loses a previously advertised game root. | The other peer receives a library update without dropping the peer, and `list-games` no longer shows that remote game. |
|
||||
| S23 | Version bump propagation | Two connected peers are running; one peer's ready game root starts with a stale `version.ini`, then changes to the catalog version. | The other peer receives a library update without reconnecting; the stale row is absent before the change, then the catalog-version game appears as downloadable. |
|
||||
| S24 | Two clients pull from one source | Two empty clients connect to the same source and download the same large game concurrently. | Both downloads finish, both receivers match the source by diff or SHA-256, and the source remains responsive. |
|
||||
| S25 | One client downloads two games concurrently | One client connected to a source issues two different `download` commands without waiting for the first to finish. | Both operations may run in parallel; both eventually finish, each game reaches the requested install state, and each transferred root matches its source. |
|
||||
| S26 | Same-game duplicate download rejection | A client starts downloading a game, then issues a second `download` command for the same game while the first operation is active. | The second request is rejected deterministically as an operation-in-progress condition; the first download is not corrupted and still reaches its documented final state. |
|
||||
| S27 | Self-connect rejection | A peer sends `connect` to its own advertised listener address. | The CLI command fails cleanly (CLI-level guard), no self-peer entry is created, and the peer remains responsive. The protocol-level guard (a hello whose `peer_id` equals the local id is acknowledged but never recorded) is covered by the `handshake::tests::inbound_hello_from_self_is_ignored` unit test, which the CLI string-compare never reaches. |
|
||||
| S28 | Address change without identity change | A known peer is rediscovered with the same peer ID and a different listener address while its library is still known. | The peer record updates in place to the new address, the existing library stays attached to that peer ID, and no duplicate peer entry appears. This is covered with a deterministic unit-level check until the CLI can rebind a live listener without restart. |
|
||||
| S29 | Empty-library peer participates | A peer with no games connects into the mesh. | Other peers list it as a peer with zero games; it can receive a download, advertise the new game without restart, and become a source. |
|
||||
| S30 | 5+ peer mesh aggregation | Five peers advertise partially overlapping catalog games with a mix of unique and shared catalog-version games; a sixth client connects to all five. | The client shows one row per game ID, correct catalog-version ready-source `peer_count`, catalog `eti_game_version`, no duplicates, and no self entries. |
|
||||
| S31 | Bootstrapped peer becomes source in same session | An empty client downloads a game from a source, the original source shuts down, then a fresh third peer downloads the same game from the bootstrapped client. | The third peer's files match the original source by diff or SHA-256, proving downloaded files become servable without restart. |
|
||||
| S32 | Reinstall after uninstall | A downloaded game is installed, uninstalled, then installed again without another download. | `local/` is recreated from preserved root files, no transfer events occur during reinstall, and the game returns to `installed=true`. |
|
||||
| S33 | Install after external root mutation | A downloaded game root is externally mutated before `install` is issued. | The CLI fixture installer installs from the current root bytes. The resulting `local/fixture-payload.txt` must match the mutated archive bytes exactly. |
|
||||
| S34 | Many-small-files game without `.eti` | A catalog game root contains `version.ini` plus many small regular files and no archive. | Download with `install=false` transfers every file, chunk events are coherent for small files, and source/receiver manifests match exactly. |
|
||||
| S35 | Unknown game ID from remote peer | A remote peer advertises a game ID that is not in the receiver's catalog. | The receiver does not list the unknown game as downloadable, download attempts fail deterministically, and no local files are created. |
|
||||
| S36 | Catalog singleton beats stale majority | Five peers advertise one game; one peer has the catalog version and four peers have stale versions. | `list-games` reports `peer_count=1` and the catalog `eti_game_version`; all descriptors and chunks come from the singleton catalog-version peer, while stale peers remain hidden and contribute zero bytes. |
|
||||
| S37 | Single-source download throughput | A source peer advertises a temporary catalog game with one sparse `2 GiB` `.eti`; an empty client downloads it with `install=false`. | The client emits `download-finished` with throughput measurements (`bytes`, `duration_ms`, `mib_per_s`, `mbit_per_s`), and the downloaded archive size matches the source. |
|
||||
| S38 | First-play launch-setting stamping | `fixture-persona/css` ships a real RAR `.eti` whose tree buries a CRLF `SmartSteamEmu.ini` with a stub `PersonaName` line under `engine/bin/win64/steam_settings/`, plus a stub `account_name.txt` and `language.txt` under `profiles/local/`. A peer installs `css` (with `--unrar`), then sends `play css` with a username and language, then `play css` again. | After install the marker `games/css/launch_settings_applied` is absent and the stub files are intact under `local/`. The first `play` returns `already_applied=false` with `account_name_written`, `language_written`, and `persona_name_written` all true; the deep `SmartSteamEmu.ini` `PersonaName` value becomes the username with its `\r\n` ending and sibling lines preserved, `account_name.txt` becomes the username, `language.txt` becomes the passed language, and the marker now exists. A second `play` returns `already_applied=true`, rewrites nothing, and leaves the files untouched even if their values were reset externally. |
|
||||
| S39 | Streamed install without keeping archive payload | Empty client connects to `fixture-bravo`, then sends `stream-install cnctw`. The source has real RAR `.eti` payload entries under `bin/` and `data/`; the receiver uses the container-bundled `unrar` stream provider. | Client emits `download-begin`, streamed `download-chunk-finished`, `download-finished`, and `install-finished` (the install-start transition is observable via `active-operations-changed`; there is no separate `install-begin` event). Local `cnctw` is `downloaded=false`, `installed=true`, `availability=LocalOnly`; root `version.ini` and `.eti` are absent; `local/bin/cnctw-payload.bin` and `local/data/cnctw-assets.dat` match `unrar p` output by SHA-256; the source reports no active outbound transfer for `cnctw` after completion. |
|
||||
| S40 | Streamed install receiver is not a peer source | After S39, a third peer connects only to the streamed-install receiver. | The third peer may see the receiver's local-only summary in peer snapshots, but `list-games` remote aggregation does not expose `cnctw` as downloadable, `peer_count` remains zero/absent, and attempting `download cnctw` fails with no local files created. |
|
||||
| S41 | Solid archive streamed install | Empty client connects to a peer serving `fixture-solid/cnctw`, whose `.eti` is a real solid RAR archive. The receiver uses the container-bundled `unrar` stream provider. | The fixture is verified as solid with `unrar lt`; streamed install finishes with `downloaded=false`, `installed=true`, `availability=LocalOnly`; root archive and `version.ini` are absent; streamed byte count equals the extracted solid entries; local payload SHA-256 hashes match `unrar p` output. |
|
||||
| S42 | Streamed install whole-stream retry | Empty client connects to two peers serving the same catalog-version `cnctw`: one broken source whose `--unrar` path is missing, followed by one good source. | The broken source sorts before the good source in retry order, contributes zero chunks, and the good source completes a fresh whole-stream attempt. The final state is local-only installed, no root archive/sentinel, no `.local.installing`, byte count matches the extracted entries, and payload hashes match the good source. |
|
||||
| S43 | Already-installed streamed install rejection | A client first stream-installs `cnctw`, then attempts `stream-install cnctw` again. | The second request emits `download-failed`, does not emit a new success event, leaves the existing local-only install intact, and clears active operations. |
|
||||
| S44 | Corrupt archive streamed install rollback | A source advertises catalog-version `cnctw`, but its root `.eti` is replaced with invalid bytes before the client requests `stream-install cnctw`. | The stream emits `download-failed`, does not emit download/install success, clears active operations, and leaves no `local/`, `.local.installing`, root archive, or root `version.ini` on the receiver. |
|
||||
| S45 | Sender disconnect during streamed install | A source serves large catalog-version `alienswarm`; after the client receives the first streamed chunk, the source container is killed. | The operation reaches a terminal failure/peers-gone event, emits no download/install success, clears active operations, and rolls back local/staging state. |
|
||||
| S46 | Receiver cancel during streamed install | A client starts streaming large catalog-version `alienswarm`, receives the first chunk, then sends `cancel-download alienswarm`. | The receiver cancels without emitting download/install success or a user-visible download failure, clears active operations, and rolls back local/staging state. |
|
||||
| S47 | Multi-archive streamed install order | A source serves `fixture-multi/cnctw` with two root `.eti` archives named to require sorted processing. | Streamed chunk paths arrive in root archive sort order, both payloads install under `local/`, the receiver is local-only installed, and no root archives or sentinel are committed. |
|
||||
| S48 | Call to Play replication and late join | Alice and Bob connect; Alice publishes a call, then Bob publishes an RSVP and chat message. Charlie joins afterward and handshakes with Alice. | Alice and Bob receive the live events, while Charlie reconstructs the same three-event history during handshake with no duplicate event IDs. |
|
||||
|
||||
## Version-Skew Contract
|
||||
|
||||
@@ -65,8 +65,8 @@ but only some match the local catalog version:
|
||||
- The receiver's catalog is authoritative. A remote root whose `version.ini`
|
||||
does not match the catalog's expected version for that game ID is not
|
||||
downloadable.
|
||||
- `list-games` aggregates by game ID. The game appears once; `peer_count`
|
||||
counts only ready peers with that ID and the catalog version.
|
||||
- `list-games` aggregates by game ID. The game appears once; `peer_count` counts
|
||||
only ready peers with that ID and the catalog version.
|
||||
- The aggregated `eti_game_version` must be the catalog version.
|
||||
- The descriptor set emitted to the download path, file-size validation, and
|
||||
transfer planning are catalog-version-only. Stale peers must not supply
|
||||
@@ -92,14 +92,14 @@ GUI:
|
||||
deltas; reconnect is not required for add, remove, or version-bump cases.
|
||||
- Same-game operations are single-flight. A duplicate download request while a
|
||||
game is already active is rejected instead of starting another writer.
|
||||
- Unknown remote game IDs are filtered by the receiver's current catalog and
|
||||
are not downloadable.
|
||||
- Unknown remote game IDs are filtered by the receiver's current catalog and are
|
||||
not downloadable.
|
||||
|
||||
For a manual run, prefer a catalog game ID already served by the fixture lab,
|
||||
such as `cnc4`, then create temporary `just peer-cli-run` game roots where some
|
||||
peers match the catalog version and others deliberately use stale
|
||||
`version.ini` contents. The existing alpha/bravo/charlie fixtures cover
|
||||
duplicate-source and shared-game cases; S15-S17 add the focused skew cases.
|
||||
peers match the catalog version and others deliberately use stale `version.ini`
|
||||
contents. The existing alpha/bravo/charlie fixtures cover duplicate-source and
|
||||
shared-game cases; S15-S17 add the focused skew cases.
|
||||
|
||||
## First-Play Launch-Setting Contract
|
||||
|
||||
@@ -112,7 +112,7 @@ Use S38 to pin down how launcher settings are stamped into an installed game:
|
||||
first `SmartSteamEmu.ini` `PersonaName` line, and the language into the first
|
||||
`language.txt`, searching the whole `local/` tree. The matched `PersonaName`
|
||||
line keeps its existing line ending (`\n` or `\r\n`).
|
||||
- The marker records only that we *tried*: it is written unconditionally after
|
||||
- The marker records only that we _tried_: it is written unconditionally after
|
||||
the first play, so a game with none of these files is still marked done.
|
||||
- S38 needs a real archive expanded with `--unrar`; the Docker matrix image now
|
||||
carries the Linux sidecar for streamed-install coverage, while the peer
|
||||
@@ -128,8 +128,8 @@ Use S39-S41 to pin down low-disk streamed installs:
|
||||
- Non-solid and solid archives both install into `local/` without committing a
|
||||
root archive or root `version.ini`, so the receiver is installed but not a
|
||||
downloadable source.
|
||||
- Streamed install integrity is currently sender archive integrity: size and
|
||||
RAR CRC32 must match the sender's archive metadata. The SHA-256 checks in the
|
||||
- Streamed install integrity is currently sender archive integrity: size and RAR
|
||||
CRC32 must match the sender's archive metadata. The SHA-256 checks in the
|
||||
scenarios prove the Docker/provider path matches the source fixture; they are
|
||||
not catalog-owned trust anchors.
|
||||
- S41 verifies the fixture is actually solid inside the source container, so
|
||||
@@ -147,8 +147,8 @@ Use S39-S41 to pin down low-disk streamed installs:
|
||||
### 2026-07-21 - Call to Play Transport (S48)
|
||||
|
||||
- Added JSONL commands to publish and inspect Call to Play events.
|
||||
- S2 passed against the rebuilt image, preserving bidirectional library
|
||||
exchange after the protocol version bump.
|
||||
- S2 passed against the rebuilt image, preserving bidirectional library exchange
|
||||
after the protocol version bump.
|
||||
- S48 passed against the rebuilt image: create, RSVP, and chat propagated live,
|
||||
then a late third peer received the same deduplicated history in handshake.
|
||||
|
||||
@@ -171,9 +171,9 @@ Use S39-S41 to pin down low-disk streamed installs:
|
||||
source kill (retry-onto-survivor is the mechanism, exercised when the kill
|
||||
interrupts an unfinished chunk, but not asserted since the race can't be
|
||||
forced).
|
||||
- S7: added chunk-source, both-sources-served, single-`download-finished`,
|
||||
and no-duplicate-chunk checks (the byte-identical `ggoo` fixtures made the
|
||||
old diff-only assertion source-agnostic).
|
||||
- S7: added chunk-source, both-sources-served, single-`download-finished`, and
|
||||
no-duplicate-chunk checks (the byte-identical `ggoo` fixtures made the old
|
||||
diff-only assertion source-agnostic).
|
||||
- S14: `4 * CHUNK_SIZE` file so the balance check is meaningful (a 3+1 split
|
||||
would now exceed one chunk); asserts an exact 2+2 split and full byte total.
|
||||
- S16: inflated `.eti` to `2 * CHUNK_SIZE` so it fans out across both
|
||||
@@ -208,29 +208,33 @@ Use S39-S41 to pin down low-disk streamed installs:
|
||||
- Accepted as-is (reviewed, deliberately not changed): S20 (disk-full via chunk
|
||||
`write_all` is equivalent coverage), S21 (inotify across the bind mount is
|
||||
inherent to the harness), S30 (dup-row/self-peer checks are cheap defensive
|
||||
guards), S32/S39/S44 absence checks (cheap regression guards against committing
|
||||
a root sentinel), S42 IP-order precondition (deterministic by container start
|
||||
order), S45 (the spec already names both terminal events).
|
||||
guards), S32/S39/S44 absence checks (cheap regression guards against
|
||||
committing a root sentinel), S42 IP-order precondition (deterministic by
|
||||
container start order), S45 (the spec already names both terminal events).
|
||||
- Live runs against the rebuilt `lanspread-peer-cli:dev` image: baseline S1-S47
|
||||
passed; post-fix S1-S47 passed. Post-fix evidence: S14 `{268435456, 268435456}`
|
||||
(balanced 2+2); S16 `.eti` split across B and C `{134217728, 134217728}`; S18
|
||||
all `536870912` bytes delivered despite the source drop (the survivor served
|
||||
the whole archive in that run); S19 deterministic `download-failed`; S37
|
||||
`874.24 MiB/s`. Gates: `just test` (incl. the new handshake test),
|
||||
`just clippy` (`-D warnings`), and `just fmt` all passed.
|
||||
passed; post-fix S1-S47 passed. Post-fix evidence: S14
|
||||
`{268435456, 268435456}` (balanced 2+2); S16 `.eti` split across B and C
|
||||
`{134217728, 134217728}`; S18 all `536870912` bytes delivered despite the
|
||||
source drop (the survivor served the whole archive in that run); S19
|
||||
deterministic `download-failed`; S37 `874.24 MiB/s`. Gates: `just test` (incl.
|
||||
the new handshake test), `just clippy` (`-D warnings`), and `just fmt` all
|
||||
passed.
|
||||
|
||||
### 2026-06-20 - Prune Dead Lifecycle Events
|
||||
|
||||
- Code under test removed the unconsumed `InstallGameBegin`, `UninstallGameBegin`,
|
||||
and `RemoveDownloadedGameBegin` `PeerEvent` variants (and their peer-cli JSONL
|
||||
- Code under test removed the unconsumed `InstallGameBegin`,
|
||||
`UninstallGameBegin`, and `RemoveDownloadedGameBegin` `PeerEvent` variants
|
||||
(and their peer-cli JSONL
|
||||
`install-begin`/`uninstall-begin`/`remove-download-begin` events), plus the
|
||||
Tauri webview emits that no frontend listener consumed (`peer-local-ready`,
|
||||
`game-download-begin`, `game-download-pre`, `game-download-finished`,
|
||||
`game-uninstall-finished`, `peer-connected`/`-disconnected`/`-discovered`/`-lost`).
|
||||
`peer-runtime-failed` was kept pending a UI decision.
|
||||
`game-uninstall-finished`,
|
||||
`peer-connected`/`-disconnected`/`-discovered`/`-lost`). `peer-runtime-failed`
|
||||
was kept pending a UI decision.
|
||||
- Rationale: the GUI is state-as-source-of-truth (it renders the `games-list`
|
||||
snapshot), and no scenario asserted these begin events; the install, uninstall,
|
||||
and removal start transitions stay observable via `active-operations-changed`.
|
||||
snapshot), and no scenario asserted these begin events; the install,
|
||||
uninstall, and removal start transitions stay observable via
|
||||
`active-operations-changed`.
|
||||
- Contract update: the S39 row no longer lists `install-begin`. Older run-log
|
||||
entries below predate the removal and are left intact as historical records.
|
||||
- Gates: `just test`, `just clippy`, `just frontend-test`, and `just build`
|
||||
@@ -244,8 +248,9 @@ Use S39-S41 to pin down low-disk streamed installs:
|
||||
catalog, made `run_extended_scenarios.py` stamp generated fixture games with
|
||||
catalog versions by default, updated S15-S17/S23/S30/S36/S37 to assert
|
||||
catalog-authoritative aggregation, and wired S38 into the executable matrix.
|
||||
- Gates before Docker: `python3 -m py_compile
|
||||
crates/lanspread-peer-cli/scripts/run_extended_scenarios.py` passed.
|
||||
- Gates before Docker:
|
||||
`python3 -m py_compile crates/lanspread-peer-cli/scripts/run_extended_scenarios.py`
|
||||
passed.
|
||||
- Targeted rebuilt-image runner:
|
||||
`python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py S3 S8 S14 S15 S16 S17 S21 S22 S23 S24 S29 S30 S31 S34 S36 S37 S39 S40 S41 S42 S43 S44 S45 S46 S47 --build-image`
|
||||
passed.
|
||||
@@ -254,20 +259,21 @@ Use S39-S41 to pin down low-disk streamed installs:
|
||||
passed, proving the real-RAR `css` fixture installs with the container
|
||||
`/usr/local/bin/unrar` sidecar and stamps launch settings only once.
|
||||
- Full matrix runner:
|
||||
`python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py`
|
||||
passed for S1-S47 against the rebuilt `lanspread-peer-cli:dev` image.
|
||||
- The final full-run highlights included S3 aggregation, S15-S17
|
||||
catalog-version skew/fanout/conflict, S23 stale-to-catalog propagation, S30
|
||||
mesh aggregation, S36 catalog singleton over stale majority, S37 throughput,
|
||||
S38 first-play stamping, and S39-S47 streamed-install coverage.
|
||||
`python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py` passed
|
||||
for S1-S47 against the rebuilt `lanspread-peer-cli:dev` image.
|
||||
- The final full-run highlights included S3 aggregation, S15-S17 catalog-version
|
||||
skew/fanout/conflict, S23 stale-to-catalog propagation, S30 mesh aggregation,
|
||||
S36 catalog singleton over stale majority, S37 throughput, S38 first-play
|
||||
stamping, and S39-S47 streamed-install coverage.
|
||||
|
||||
### 2026-06-07 - Streamed Install Edge Coverage (S43-S47)
|
||||
|
||||
- Code under test added `cancel-download` to `lanspread-peer-cli`, added the
|
||||
tiny `fixture-multi/cnctw` two-archive fixture, and added S43-S47 in
|
||||
`run_extended_scenarios.py`.
|
||||
- Gates before Docker: `just fmt` and `python3 -m py_compile
|
||||
crates/lanspread-peer-cli/scripts/run_extended_scenarios.py` passed.
|
||||
- Gates before Docker: `just fmt` and
|
||||
`python3 -m py_compile crates/lanspread-peer-cli/scripts/run_extended_scenarios.py`
|
||||
passed.
|
||||
- Runner:
|
||||
`python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py S43 S44 S45 S46 S47 --build-image`
|
||||
passed against the rebuilt `lanspread-peer-cli:dev` image.
|
||||
@@ -279,8 +285,8 @@ Use S39-S41 to pin down low-disk streamed installs:
|
||||
- S45 killed the sole `alienswarm` source after the first streamed chunk. The
|
||||
receiver ended with `download-failed`, emitted no success, cleared active
|
||||
operations, and rolled back local/staging state.
|
||||
- S46 cancelled `alienswarm` on the receiver after the first streamed chunk.
|
||||
The receiver emitted no success and no user-visible `download-failed`, cleared
|
||||
- S46 cancelled `alienswarm` on the receiver after the first streamed chunk. The
|
||||
receiver emitted no success and no user-visible `download-failed`, cleared
|
||||
active operations, and rolled back local/staging state.
|
||||
- S47 streamed `fixture-multi/cnctw` and observed chunk paths in sorted root
|
||||
archive order: `cnctw/.local.installing/order/first.txt`, then
|
||||
@@ -289,8 +295,9 @@ Use S39-S41 to pin down low-disk streamed installs:
|
||||
### 2026-06-07 - Streamed Install Whole-Stream Retry (S42)
|
||||
|
||||
- Code under test added S42 in `run_extended_scenarios.py`.
|
||||
- Gates before Docker: `python3 -m py_compile
|
||||
crates/lanspread-peer-cli/scripts/run_extended_scenarios.py` passed.
|
||||
- Gates before Docker:
|
||||
`python3 -m py_compile crates/lanspread-peer-cli/scripts/run_extended_scenarios.py`
|
||||
passed.
|
||||
- Runner:
|
||||
`python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py S42`
|
||||
passed against the current `lanspread-peer-cli:dev` image.
|
||||
@@ -300,14 +307,14 @@ Use S39-S41 to pin down low-disk streamed installs:
|
||||
- The broken source contributed zero chunks; the good source completed the fresh
|
||||
whole-stream attempt with `3145728` streamed file bytes.
|
||||
- The final client state was `downloaded=false`, `installed=true`,
|
||||
`availability=LocalOnly`, with no root `version.ini`, no root `cnctw.eti`,
|
||||
and no `.local.installing` staging directory. Payload SHA-256 hashes matched
|
||||
the good source's `unrar p` output.
|
||||
`availability=LocalOnly`, with no root `version.ini`, no root `cnctw.eti`, and
|
||||
no `.local.installing` staging directory. Payload SHA-256 hashes matched the
|
||||
good source's `unrar p` output.
|
||||
|
||||
### 2026-06-07 - Solid Streamed Install Coverage (S41)
|
||||
|
||||
- Code under test added `fixture-solid/cnctw`, a real solid RAR `.eti`, plus
|
||||
S41 in `run_extended_scenarios.py`.
|
||||
- Code under test added `fixture-solid/cnctw`, a real solid RAR `.eti`, plus S41
|
||||
in `run_extended_scenarios.py`.
|
||||
- Gates before Docker: `just fmt`, `git diff --check`, and
|
||||
`python3 -m py_compile crates/lanspread-peer-cli/scripts/run_extended_scenarios.py`
|
||||
passed.
|
||||
@@ -336,17 +343,17 @@ Use S39-S41 to pin down low-disk streamed installs:
|
||||
- Runner:
|
||||
`python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py S39 S40 --build-image`
|
||||
passed against the rebuilt `lanspread-peer-cli:dev` image.
|
||||
- S39 streamed a catalog-version-adjusted `cnctw` fixture from a real RAR
|
||||
`.eti` into the receiver's `local/` only. The receiver had
|
||||
`downloaded=false`, `installed=true`, `availability=LocalOnly`, no root
|
||||
`version.ini`, no root `.eti`, and payload SHA-256 hashes
|
||||
- S39 streamed a catalog-version-adjusted `cnctw` fixture from a real RAR `.eti`
|
||||
into the receiver's `local/` only. The receiver had `downloaded=false`,
|
||||
`installed=true`, `availability=LocalOnly`, no root `version.ini`, no root
|
||||
`.eti`, and payload SHA-256 hashes
|
||||
`82f4da22dc042166def2a5ee2eca19fc9e52785f99838e86c32167cb342e2588`
|
||||
(`bin/cnctw-payload.bin`) and
|
||||
`abf833a06c74ea9f17d505c2684186491898ce906405e0f098f0deac19476b06`
|
||||
(`data/cnctw-assets.dat`) matching `unrar p`.
|
||||
- S40 connected an observer only to that streamed-install receiver. The
|
||||
observer saw the receiver's `cnctw` summary as local-only, remote aggregation
|
||||
hid it as a downloadable source, and `download cnctw` failed with
|
||||
- S40 connected an observer only to that streamed-install receiver. The observer
|
||||
saw the receiver's `cnctw` summary as local-only, remote aggregation hid it as
|
||||
a downloadable source, and `download cnctw` failed with
|
||||
`no peers have game cnctw`.
|
||||
|
||||
### 2026-05-28 - First-Play Launch-Setting Stamping (S38)
|
||||
@@ -369,9 +376,9 @@ Use S39-S41 to pin down low-disk streamed installs:
|
||||
|
||||
### 2026-05-19 - Snapshot Status Fix Docker Matrix Pass
|
||||
|
||||
- Code under test included `5c4976d` (`fix(peer): settle local state before
|
||||
clearing operations`) and `6651f02` (`fix(ui): derive operation status from
|
||||
snapshots`).
|
||||
- Code under test included `5c4976d`
|
||||
(`fix(peer): settle local state before clearing operations`) and `6651f02`
|
||||
(`fix(ui): derive operation status from snapshots`).
|
||||
- Gates before the matrix: `just fmt`, `just test`, `just frontend-test`, and
|
||||
`just build` passed. The peer harness image was rebuilt with
|
||||
`just peer-cli-image`.
|
||||
@@ -383,10 +390,10 @@ Use S39-S41 to pin down low-disk streamed installs:
|
||||
- Large/exact transfer coverage remained good: S13 small and large downloads
|
||||
diffed cleanly; S14 split `alienswarm` between two sources with chunk totals
|
||||
`67,108,864` and `58,721,049` bytes and the final root diffed cleanly.
|
||||
- Failure and mutation coverage remained good: S17 latest-version conflict,
|
||||
S19 sole-source drop, S20 write failure, S26 duplicate operation, and S35
|
||||
unknown catalog filtering all failed safely without advertising bad local
|
||||
state; S21-S23 propagation, S24-S25 concurrency, S29-S31 bootstrapping, S32
|
||||
- Failure and mutation coverage remained good: S17 latest-version conflict, S19
|
||||
sole-source drop, S20 write failure, S26 duplicate operation, and S35 unknown
|
||||
catalog filtering all failed safely without advertising bad local state;
|
||||
S21-S23 propagation, S24-S25 concurrency, S29-S31 bootstrapping, S32
|
||||
reinstall, S33 mutation install, S34 many-small-files, and S36 latest
|
||||
singleton all passed.
|
||||
|
||||
@@ -459,14 +466,14 @@ Use S39-S41 to pin down low-disk streamed installs:
|
||||
`game mystery-game is not in the local catalog`, and no local files were
|
||||
created.
|
||||
- S36 latest singleton: with one peer on `20260501` and four peers on
|
||||
`20250101`, the client reported `peer_count=5` and latest `20260501`; only
|
||||
the singleton latest peer sent chunks and the final root diffed cleanly.
|
||||
`20250101`, the client reported `peer_count=5` and latest `20260501`; only the
|
||||
singleton latest peer sent chunks and the final root diffed cleanly.
|
||||
|
||||
### 2026-05-18 - Full Matrix Manual Docker Pass
|
||||
|
||||
- Build/setup: `just peer-cli-image` passed. Local `just peer-cli-build`
|
||||
needed `RUSTC_WRAPPER=` because the host `kache` wrapper failed with a
|
||||
read-only filesystem error; `RUSTC_WRAPPER= just peer-cli-build` passed.
|
||||
- Build/setup: `just peer-cli-image` passed. Local `just peer-cli-build` needed
|
||||
`RUSTC_WRAPPER=` because the host `kache` wrapper failed with a read-only
|
||||
filesystem error; `RUSTC_WRAPPER= just peer-cli-build` passed.
|
||||
- Temporary skew/conflict fixtures were created under the ignored
|
||||
`.lanspread-peer-cli/full-fixtures/` tree using `rar a -idq -m0` against
|
||||
`/dev/urandom` payloads and then renaming the archives to `.eti`.
|
||||
@@ -481,12 +488,11 @@ Use S39-S41 to pin down low-disk streamed installs:
|
||||
- S3 clean remote aggregation: an empty `clean-s3-client` saw exactly alpha and
|
||||
bravo. `list-games` showed `ggoo peer_count=2`; `alienswarm`, `bf1942`,
|
||||
`bfbc2`, `cnc4`, and `cnctw` each had `peer_count=1`.
|
||||
- S4 single-source no-install: `full-empty-client` downloaded `bfbc2` from
|
||||
bravo with `install=false`. Events included `got-game-files`,
|
||||
`download-begin`, `download-finished`, and local `installed=false`. Host
|
||||
verification: `diff -r crates/lanspread-peer-cli/fixtures/fixture-bravo/bfbc2
|
||||
.lanspread-peer-cli/full-empty-client/games/bfbc2` passed and `local/` was
|
||||
absent.
|
||||
- S4 single-source no-install: `full-empty-client` downloaded `bfbc2` from bravo
|
||||
with `install=false`. Events included `got-game-files`, `download-begin`,
|
||||
`download-finished`, and local `installed=false`. Host verification:
|
||||
`diff -r crates/lanspread-peer-cli/fixtures/fixture-bravo/bfbc2 .lanspread-peer-cli/full-empty-client/games/bfbc2`
|
||||
passed and `local/` was absent.
|
||||
- S5 auto-install: `full-empty-client` downloaded `cnctw` with default install.
|
||||
Events included download finish, `install-begin`, and `install-finished`;
|
||||
`local/fixture-payload.txt` existed. Host verification diffed the downloaded
|
||||
@@ -510,12 +516,13 @@ Use S39-S41 to pin down low-disk streamed installs:
|
||||
bravo-only remote games. After bravo `shutdown`, alpha emitted `peer-lost`;
|
||||
`list-peers` returned `[]` and `list-games` returned an empty remote list.
|
||||
- S11 same identity reconnect: restarting bravo reused peer ID
|
||||
`019e347d901e70c19adf5b9fd313fce4` at new address `10.66.0.3:41764`.
|
||||
Alpha `list-peers` showed exactly one bravo entry at the new address.
|
||||
- S12 transfer serving gates: this remains covered by unit tests because the
|
||||
CLI cannot stably race raw transfer requests against non-catalog, missing
|
||||
sentinel, active-operation, and `local/` path states. `RUSTC_WRAPPER= just
|
||||
test` passed, including `local_download_available_gates_on_catalog_operation_and_sentinel`,
|
||||
`019e347d901e70c19adf5b9fd313fce4` at new address `10.66.0.3:41764`. Alpha
|
||||
`list-peers` showed exactly one bravo entry at the new address.
|
||||
- S12 transfer serving gates: this remains covered by unit tests because the CLI
|
||||
cannot stably race raw transfer requests against non-catalog, missing
|
||||
sentinel, active-operation, and `local/` path states.
|
||||
`RUSTC_WRAPPER= just test` passed, including
|
||||
`local_download_available_gates_on_catalog_operation_and_sentinel`,
|
||||
`get_game_response_respects_serve_gates`,
|
||||
`file_transfer_dispatch_respects_serve_gates`, and
|
||||
`local_relative_paths_are_never_transferable`.
|
||||
@@ -529,20 +536,20 @@ Use S39-S41 to pin down low-disk streamed installs:
|
||||
`67,108,864` bytes from alpha and `58,721,049` bytes from the staged peer,
|
||||
balanced within one `32 MiB` chunk. Final host `diff -r` against
|
||||
`fixture-alpha/alienswarm` passed.
|
||||
- S15 three-way version skew: peers A/B/C advertised `cnc4` versions
|
||||
`20250101`, `20250201`, and `20250301`. The client saw one row with
|
||||
`peer_count=3` and `eti_game_version=20250301`; all chunks came only from C
|
||||
at `10.66.0.4:60290`. Host `diff -r` against C passed.
|
||||
- S15 three-way version skew: peers A/B/C advertised `cnc4` versions `20250101`,
|
||||
`20250201`, and `20250301`. The client saw one row with `peer_count=3` and
|
||||
`eti_game_version=20250301`; all chunks came only from C at `10.66.0.4:60290`.
|
||||
Host `diff -r` against C passed.
|
||||
- S16 latest-version fanout with stale peer present: A advertised stale
|
||||
`20250101`; B/C both advertised latest `20250301` with a `134,217,906` byte
|
||||
`.eti`. The client saw `peer_count=3`; chunks came only from B/C
|
||||
(`67,108,873` and `67,109,042` bytes respectively), with stale A contributing
|
||||
zero. Host `diff -r` matched both B and C.
|
||||
- S17 latest-version conflict rejection: A advertised stale `20250101`; B/C
|
||||
both advertised latest `20250301` but with conflicting `.eti` sizes
|
||||
(`1,048,748` and `2,097,325` bytes). The client saw `peer_count=3` and latest
|
||||
`20250301`, then `download cnc4` emitted `download-failed`; no target
|
||||
`cnc4/version.ini` was committed.
|
||||
`.eti`. The client saw `peer_count=3`; chunks came only from B/C (`67,108,873`
|
||||
and `67,109,042` bytes respectively), with stale A contributing zero. Host
|
||||
`diff -r` matched both B and C.
|
||||
- S17 latest-version conflict rejection: A advertised stale `20250101`; B/C both
|
||||
advertised latest `20250301` but with conflicting `.eti` sizes (`1,048,748`
|
||||
and `2,097,325` bytes). The client saw `peer_count=3` and latest `20250301`,
|
||||
then `download cnc4` emitted `download-failed`; no target `cnc4/version.ini`
|
||||
was committed.
|
||||
- Gates after manual runs: `just fmt`, `RUSTC_WRAPPER= just test`, and
|
||||
`RUSTC_WRAPPER= just clippy` passed.
|
||||
|
||||
@@ -567,9 +574,9 @@ Use S39-S41 to pin down low-disk streamed installs:
|
||||
`3832bcb7057a4453981e975d2d2d528bfd9a26671423352f4a8527362d5b9810`;
|
||||
`alienswarm/version.ini`
|
||||
`8dfdc51d4dbfb06015b41a85a5f5d47f44144139e4a12db2b17eb040773082a3`.
|
||||
- S14 multi-peer setup: `deep-stage-c` connected to alpha
|
||||
(`10.66.0.3:53514`) and `deep-stage-b` (`10.66.0.2:58491`). `list-games`
|
||||
showed `alienswarm` with `peer_count=2` before the download.
|
||||
- S14 multi-peer setup: `deep-stage-c` connected to alpha (`10.66.0.3:53514`)
|
||||
and `deep-stage-b` (`10.66.0.2:58491`). `list-games` showed `alienswarm` with
|
||||
`peer_count=2` before the download.
|
||||
- S14 chunk-source evidence for `alienswarm/alienswarm.eti`: `deep-stage-c`
|
||||
received chunks from `deep-stage-b` at offsets `0` and `67,108,864`
|
||||
(`67,108,864` bytes total) and from alpha at offsets `33,554,432` and
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
| Time’s 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 |
|
||||
| Time’s 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 |
|
||||
|
||||
“Time’s 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.
|
||||
“Time’s 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/Time’s-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/Time’s-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 event’s 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 Time’s-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 frontend’s 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 Time’s-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 frontend’s 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user