docs(plan): record peer-auth decisions and evidence

Record the completed protocol-8 implementation, its security and lifecycle
decisions, and the final local acceptance evidence. Mark protocol-7 Call to
Play relay reviews as historical so they cannot be mistaken for current design.

Keep production acceptance honest by recording the unavailable canonical
186-game manifest corpus, real Windows/NTFS confinement and durability proof,
and representative physical-LAN evidence as external release prerequisites.

Test Plan:
- `just fmt` (passed)
- `just test` (passed; 708 workspace tests, including peer 480 and Tauri 56)
- `just clippy` (passed)
- `just frontend-test` (passed; 91/91)
- `just build` (passed; fixture-backed no-bundle build)
- `LANSPREAD_S37_MIN_MIB_PER_S=100 just peer-cli-tests` (passed; S1-S49)
- `git diff --cached --check` (passed)
This commit is contained in:
2026-08-10 14:05:26 +02:00
parent e0eafa6e33
commit 0fbf589dc5
9 changed files with 1048 additions and 29 deletions
+6 -4
View File
@@ -20,11 +20,13 @@
gates; the token map is only cancellation plumbing for in-flight downloads.
- Treated a downloaded-but-not-installed game as immediately installable from
Tauri by sending `PeerCommand::InstallGame` directly. A not-downloaded game
still uses `GetGame`, and the peer auto-installs after the sentinel commit.
uses `PeerCommand::DownloadGameFiles`; the peer derives every path, size, and
hash from its local catalog manifest and auto-installs after the sentinel
commit.
- Removed the dead internal `PeerCommand::UpdateGame` path. The UI update button
now sends `FetchLatestFromPeers`, which skips local manifest serving and asks
latest-version peers for fresh file metadata before the normal download and
update transaction runs.
now sends the same exact-content download command. The local catalog remains
descriptor authority, and only authenticated peers advertising its exact
`ContentId` are eligible sources before the normal update transaction runs.
- Removed the unreachable `Availability::Downloading` protocol value. Active
operations are reported separately, and local summaries emit only settled
availability.
@@ -4,8 +4,16 @@ This file records implementation choices that are not fully determined by
`PEER_AUTH_PLAN.md`, especially choices where every available option has a
meaningful downside.
Entries describe the design at the time they were made. A supersession note is
part of the record: the older rationale remains for traceability, but must not
be read as the current protocol contract.
## 2026-08-09 — Bound protocol-7 download descriptions generously
> **Superseded by the Phase 4 protocol-8 cutover:** remote download descriptions
> were deleted. Catalog manifests and exact `ContentId` requests now bound
> transfer authority, so these wire-description limits are historical.
**TL;DR:** Until catalog manifests replace remote descriptions, accept at most
100,000 entries, 1 TiB per ordinary file, 64 KiB for the in-memory `version.ini`
sentinel, 255 bytes per path component, 900 bytes per relative path, and 16 TiB
@@ -72,6 +80,10 @@ Alternatives:
## 2026-08-09 — Bind ownership records to one canonical games directory
> Superseded on 2026-08-10 by root-namespaced ownership records. Binding the
> record was necessary but not sufficient because a second root could replace
> the single per-game slot.
**TL;DR:** A per-game ownership record includes an opaque, exact identity for
the canonical configured games directory. A record from another directory is
ignored rather than authorizing deletion in the current one.
@@ -86,8 +98,9 @@ Alternatives:
- Clear every ownership record whenever the setting changes. This is safe, but
loses useful cleanup history when the user switches back to an earlier tree.
- Nest ownership records under a games-directory key. This makes the separation
structural, but complicates all existing per-game state layout and migration.
- Nest ownership records under a games-directory key. This was later selected in
game-first form after the singleton replacement risk was proven; see
"Namespace download ownership by canonical games root" below.
- Store the canonical path as JSON text. This is easy to inspect, but cannot
represent every valid native path without a lossy conversion.
@@ -383,3 +396,964 @@ Alternatives:
- Track inode identities in the ownership ledger. This can detect later
replacement, but makes persistent state platform-specific and still cannot
prevent a local actor from changing links concurrently.
## 2026-08-09 — Keep a durable quarantine marker across download commit
**TL;DR:** From the moment pending ownership becomes durable until the settled
ownership record is durable, keep a small per-game recovery marker. A visible
`version.ini` is not advertised, served, or installed while either that marker
or a pending/invalid current ownership record says recovery is required.
This durable transaction-recovery marker is distinct from Phase 2's
runtime-local source integrity quarantine. The latter records only
`(PeerId, content_id)` failures and deliberately disappears with the peer
runtime.
The ownership record is published by rename. If finalization's rename succeeds
but syncing its parent directory fails, the visible record already says
`pending_files: null`; inspecting that JSON alone would incorrectly classify the
game as settled. The separate marker spans that ambiguity. Recovery re-publishes
the settled record durably before removing the marker, and local library
fingerprints include recovery readiness so a journal-only transition invalidates
cached availability.
Once the settled record is durable, a successful marker unlink is treated as
settled even if syncing that unlink's parent fails. A power loss may resurrect
the marker and cause conservative recovery on the next run, but the live process
never reports both a failed marker removal and a marker that is already visibly
absent.
Alternatives:
- Infer readiness only from `pending_files`. This uses one file, but cannot
represent the post-finalization-rename durability window safely.
- Keep transaction recovery quarantine only in memory. This is smaller on disk,
but loses the safety boundary on restart and makes exceptional task
termination able to expose a cached ready game.
- Add a larger tagged write-ahead-log schema with every transaction phase. This
can encode the same state in one artifact, but adds migration and recovery
complexity without improving the current decision table.
## 2026-08-09 — Retain admission after an unexpected mutation-task exit
**TL;DR:** If a download, install, update, uninstall, or downloaded-file removal
task disappears before its normal recovery, rescan, and explicit cleanup path,
keep that game marked active until the process restarts. Startup recovery is the
escape hatch.
Dropping an async guard cannot safely run filesystem recovery or publish a new
library snapshot. Clearing the operation entry anyway would permit another
writer to enter while install intent, staging, backup, ownership, or sentinel
state may still be unresolved. Retaining the entry is deliberately fail-closed:
other games keep working, but the affected game and a game-directory change are
blocked until restart recovery settles the state.
Alternatives:
- Spawn recovery from `Drop`. Destructors cannot await it, task/runtime shutdown
is exactly when this path is likely, and clearing the gate would still need a
proven recovery-and-publication completion point.
- Clear the entry and rely on the next command to recover. This keeps the UI
available, but admits a second mutation before the recovery boundary.
- Parse persistent transaction state on every operation and serving request.
This can self-heal without restart, but puts large journals and filesystem I/O
on hot paths and still needs coordination with cached library publication.
## 2026-08-09 — Quarantine startup recovery failures per configured root
**TL;DR:** While a games directory is being recovered, block every mutation and
serve request. After its recovery scan is safely published, block only game IDs
whose recovery failed. A same-path settings refresh retries recovery; restart
naturally retries it before networking starts.
The download ownership journal already has its own durable recovery marker.
Install recovery failures and root-enumeration errors need a runtime gate too,
but adding another persistent transaction format would duplicate state that is
reconstructed before services start on every launch. Binding the gate to the
configured root prevents a failure in one library directory from poisoning a
different directory, while the failed-ID projection keeps healthy LAN-party
games usable.
Alternatives:
- Hide the entire library after one game fails recovery. This is simpler and
safe, but one broken game unnecessarily disables every healthy game.
- Add a second durable per-game recovery marker for all install states. This
survives without rerunning recovery, but creates another crash-consistent
schema beside install intent and download ownership.
- Log recovery errors and scan normally. This maximizes availability, but can
advertise, serve, launch, or overwrite a root whose transaction state never
settled.
## 2026-08-09 — Drain outbound readers before changing or mutating a game root
**TL;DR:** File, chunk, and streamed-install senders register under the same
admission barrier as filesystem mutations and game-directory changes. A root
transition first cancels and drains every registered sender; if draining times
out, the requested transition makes no local state change.
An outbound sender may still have a package file or extractor process open.
Switching or recovering a root while that work is live can expose inconsistent
bytes and can make rename/delete behavior platform-dependent. Admission closes
the registration race, while the explicit drain makes an empty registry the
mutation boundary. A timed-out command leaves the prior root, cache, database,
and recovery quarantine unchanged so it can be retried without reconstructing an
abandoned half-transition.
If an outbound handler disappears unexpectedly, its token is cancelled but its
registry entry is deliberately retained. The affected process must restart
before a root transition is attempted again; the registry is owned outside one
peer-runtime instance, so a peer-runtime restart alone is not claimed to clear
this fail-closed state.
Alternatives:
- Let a directory switch cancel senders without waiting. This is responsive, but
cancellation is a signal rather than proof that file and extractor work has
stopped.
- Mark recovery before draining and leave the old root quarantined on timeout.
This is safe, but needlessly discards a known-good published state when no
recovery mutation ever began.
- Force-remove unexpected transfer entries after a timeout. This restores
availability, but invents quiescence and can race detached OS or provider
work.
- Clear the shared transfer registry on every peer-runtime start. This could
shorten recovery, but is safe only after proving every task from the previous
runtime has fully stopped.
## 2026-08-09 — Retain a no-follow game-root capability around install mutations
**TL;DR:** After operation admission and root-dependent revalidation, open the
configured games directory and its one direct game-ID child without following
their final components, reject non-direct/non-directory/link/reparse shapes, and
retain both handles until install, update, uninstall, recovery, or streamed
install commit/rollback finishes. The existing unpacker still receives an
ambient staging path because concurrent local root replacement is outside the
threat model.
This closes the concrete preflight hole where `<games>/<game>` could be a
symlink or Windows reparse point before an install mutation began. Each blocking
open is awaited directly, and the capability is owned by the same structured
transaction as the mutation; no child task or filesystem authority detaches past
operation completion. Local scanning and readiness use no-follow metadata for
the direct game root so an unsafe shape cannot be cached as ready while startup
recovery reports and quarantines it.
Alternatives:
- Change `Unpacker` and every install primitive to accept only handle-relative
capabilities. This is the strongest design, but external `unrar` requires a
pathname and the cross-platform directory API rewrite is much larger than the
direct-root vulnerability being closed.
- Canonicalize the game path once and continue without retained handles. This is
smaller, but follows the very link/reparse component that must be rejected and
loses object identity before the transaction ends.
- Reject unsafe roots only during library scanning. This prevents publication,
but a stale command can still reach a mutation path unless the check is
repeated after admission immediately before mutation.
## 2026-08-09 — Poll local game roots with a structured one-second snapshot
**TL;DR:** Replace the operating-system filesystem watcher with a one-second
lexical metadata snapshot of the games directory and each direct game root.
Snapshot reads run inline as finite scoped blocking work, while changed game IDs
enter a lexically owned rescan set that is drained before the monitor returns.
The previous `notify` backend owned an internal thread whose drop only requested
shutdown; dropping the watcher did not prove that thread had exited. The polling
snapshot has no callback channel or hidden worker. It compares direct entry
names, kinds, sizes, and modification times, ignores install/download-protected
children, and retains the five-minute full scan as a slower reconciliation path.
A monitor-loop unwind is caught and reported only after the same natural rescan
drain used by ordinary shutdown, so dropping the rescan set never invents child
quiescence. A one-second interval keeps sideloaded games and manual package
changes prompt enough for the desktop UI while limiting idle directory reads to
once per second.
This trades event-driven responsiveness and near-zero idle I/O for a bounded
shutdown lifetime. Metadata polling can miss an in-place rewrite that preserves
both file size and reported modification time. The periodic full scan recovers
from transient snapshot failures and reconciles broader library state, but its
archive cache uses the same size-and-time identity and does not close a
deliberately metadata-preserving rewrite edge case.
Alternatives:
- Keep `notify` and accept its backend lifetime. This gives immediate events and
avoids periodic reads, but the peer-runtime shutdown boundary cannot prove the
backend thread has stopped.
- Run a full library scan every second. This detects more state directly, but
repeatedly parses and fingerprints complete game packages when nothing has
changed.
- Poll less frequently. This reduces idle filesystem work, but makes a manual
sideload or package edit visibly laggy during a LAN party.
## 2026-08-09 — Own shared QUIC endpoints up to the provider boundary
> **Partly superseded by the Phase 4 protocol-8 cutover:** shared endpoint
> ownership and lexical connection closure remain current; the `Goodbye`
> delivery step was deleted and liveness removal is authoritative.
**TL;DR:** Use one runtime-owned outgoing QUIC client endpoint and one
server-owned incoming endpoint. A local IO-provider wrapper retains each s2n
endpoint task, every request closes its connection lexically, and shutdown
always awaits the endpoint task after application connection scopes drain.
Creating a new client endpoint for every control request repeatedly bound UDP
sockets and spawned transport work whose handle the stock s2n Tokio adapter
discarded. The shared client makes connection creation cheap and gives the peer
runtime one final network child to close after services, user operations, and
Goodbye delivery finish. Short control exchanges use one absolute deadline from
connect through response EOF; downloads and streamed installs use their
operation cancellation token plus QUIC handshake and idle bounds, avoiding a
wall-clock limit on a large LAN transfer. A connection guard initiates close on
every success, error, cancellation, deadline, or unwind path.
The wrapper depends on the public provider trait from an exactly matched
`s2n-quic-core`, so `s2n-quic` and `s2n-quic-core` are pinned together at 1.85.0
and 0.85.0. s2n starts IO last, so a successful internal endpoint spawn has no
later fallible builder step before the Client or Server is returned.
This is an application-level structured boundary, not a claim that every task
inside s2n is joinable. `s2n-quic-platform` privately spawns RX and TX workers
and discards their handles. When the owned endpoint finishes, it drops their
ring counterparts and removes their ability to reach application state; they
then wake and exit after observing the closed ring. A final private socket/ring
iteration may still occur after the endpoint join, and the public API offers no
handle with which to await it. That narrow dependency-internal exception is
accepted here because this refactor explicitly forbids vendoring or forking the
provider.
Alternatives:
- Keep the stock provider. This is less local code and looser version coupling,
but loses the endpoint handle and cannot make runtime completion a real QUIC
lifecycle boundary.
- Create one fully owned endpoint per request. This makes each request naturally
lexical, but adds socket/task churn, local port pressure, and repeated setup
for frequent control traffic.
- Fork or patch `s2n-quic-platform` to retain and join endpoint, RX, and TX
handles. This gives literal transport-worker quiescence, but adds an ongoing
security/update maintenance burden for a large networking dependency.
- Give each endpoint a dedicated Tokio runtime and join that runtime. This can
contain dependency tasks more strongly, but is operationally heavy and
complicates every async handoff for little LAN-party benefit.
## 2026-08-09 — Run finite blocking work lexically, not as detached pool work
**TL;DR:** A finite filesystem or validation operation runs inside the owning
future through `scoped_blocking`. On a multi-thread Tokio runtime this uses
`block_in_place`; on a current-thread runtime or outside Tokio it calls the
closure directly. Once entered, the operation is deliberately non-cancellable,
so its parent cannot finish or release filesystem authority until the closure
has returned.
Tokio filesystem APIs and `spawn_blocking` both submit work that may keep
running after the async future is dropped. That is incompatible with rollback,
operation-admission, and outbound-transfer guards whose destruction is supposed
to mean that mutation or reads have stopped. Whole bounded batches therefore use
synchronous file handles and one lexical blocking scope, with cancellation
checks at safe boundaries before and after the batch. Large bounded loops check
their cancellation token between entries where doing so cannot expose a partial
publication.
This favors lifetime proof over prompt forced cancellation. A stuck kernel or
filesystem call can delay shutdown, and current-thread tests execute the closure
on their only runtime thread. Production uses a multi-thread runtime, while
controlled tests release deliberately blocked closures from an operating-system
thread rather than another task on the same executor.
Alternatives:
- Drop a `spawn_blocking` handle on cancellation. Tokio cannot stop an already
running closure, so mutation could outlive its operation guard.
- Spawn a detached reaper that waits for blocking work. This eventually cleans
up, but the parent still returns before its child and violates the same
ownership boundary.
- Put every file operation on a dedicated executor. Its request still needs a
synchronous completion acknowledgement on drop; it adds a queue without
strengthening the finite-call guarantee.
## 2026-08-09 — Treat bundled extractors as trusted leaf helpers
**TL;DR:** Setup and archive-extraction helpers are owned through direct-child
termination, reap, pipe drainage, and supervisor join. They must be trusted leaf
executables that do not fork descendants. User-launched games and game servers
are explicitly transferred to the user and are not runtime children.
The bundled `unrar` is invoked with stdin closed and password prompting
disabled. Its lexical owner kills and waits for the child on cancellation,
error, or parent drop, and joins bounded stdout/stderr readers before returning.
The CLI `--unrar` override is local-authority configuration and has the same
trusted-leaf contract. A helper that forks is outside this process scope: a
descendant can otherwise retain pipes or continue mutation after the direct
child exits.
This is narrower than arbitrary process-tree structured concurrency. Rust's
portable process API cannot create and await a whole Unix process tree or a
Windows Job Object without platform-specific support, and the evaluated
`process-wrap` API does not expose a reliable all-descendants join boundary.
Supporting arbitrary helper trees would require a separate Unix
process-group/subreaper and Windows Job Object implementation plus real Windows
tests.
Alternatives:
- Assume killing the direct PID kills its descendants. That is false on both
supported process models and would overclaim rollback safety.
- Add a generic process-tree crate and rely on its kill call. Killing is not
joining; the evaluated implementation can return before every descendant has
exited.
- Treat game and server launches as peer-runtime children. That would make
normal app shutdown terminate the games the user intentionally launched.
## 2026-08-09 — Isolate the peer runtime behind a joinable supervisor thread
> **Partly superseded by the Phase 4 protocol-8 cutover:** the joinable
> supervisor and ordered child drainage remain current; references to a
> `Goodbye` child describe the removed protocol-7 shutdown path.
**TL;DR:** A peer runtime is not stopped when cancellation is requested. A
dedicated supervisor thread creates and owns its Tokio runtime, root future,
services, operations, and QUIC endpoint. Normal owners request shutdown and
await `wait_stopped`; active `PeerRuntimeHandle::Drop` is a loud misuse fallback
that cancels and synchronously joins the same supervisor without depending on
the caller's Tokio executor.
Startup uses a synchronous result handshake after runtime-local construction, so
an endpoint or root startup error/panic is joined before it is returned to the
caller. The async wait retains the sole thread handle in the owner across every
await, making cancellation retryable; completion is published only after the
isolated Tokio runtime has been destroyed. Root, Goodbye, and transport cleanup
panics are contained in order. Even an endpoint task aborted by an exceptional
destructor is bounded by runtime teardown before either `wait_stopped` or active
Drop returns. Production CLI and Tauri lifecycles still use explicit
shutdown-and-wait as their ordinary, non-blocking ownership path. As with any
strict synchronous join, active Drop must not form an ownership cycle: providers
and peer callbacks may not obtain/drop their own runtime handle, and a caller
may not hold a shared lock or resource required by shutdown while dropping it.
Such a cycle has no quiescent synchronous completion.
Alternatives:
- Keep the root task on the caller's Tokio runtime. Its destructor cannot
synchronously join a current-thread executor without deadlocking, so active
Drop can only detach or overclaim quiescence.
- Use `block_in_place` or drive the caller runtime from Drop. This is
unavailable on current-thread runtimes and makes peer progress depend on the
executor that is currently blocked.
- Spawn a cleanup task or reaper from Drop. That merely detaches the lifetime
under a different name and lets the public owner return first.
- Abort the root task and report it stopped. Abort is a cancellation request; it
skips ordered child drainage, Goodbye, and provider cleanup.
## 2026-08-09 — Await fanout and directory transitions through publication
> **Partly superseded by the Phase 4 protocol-8 cutover:** `SetGameDir`
> admission and acknowledged publication remain current. Library deltas and
> Call-to-Play event fanout were replaced by bounded revision hints and pinned
> full pulls.
**TL;DR:** Concurrent peer deliveries remain lexically owned and are all awaited
before a publication returns. `SetGameDir` holds operation admission through
outbound drain, recovery, scan, cache/database publication, and its acknowledged
result; callers persist or display only the canonical path returned on success.
Returning after merely starting delta or Call-to-Play sends lets operation
guards and directory admission clear while child QUIC work still references the
old state. Awaited fanout preserves concurrency without losing the parent-child
boundary. A directory transition is intentionally slower because its command
completion now means the new root has recovered and its projected library state
has been published. Rejection or drain timeout leaves both backend and UI on the
previous root.
Alternatives:
- Queue delivery on the global runtime tracker. Shutdown eventually drains it,
but the operation or publication that created it can finish first.
- Acknowledge a directory change immediately and recover in the background. This
makes settings feel faster, but permits commands and UI state to race a root
whose recovery status is not known.
- Persist the requested path before core acknowledgement. A rejected core
transition then leaves durable UI configuration disagreeing with the active
peer root.
## 2026-08-09 — Fail closed on interrupted catalog publication
**TL;DR:** Generate and independently verify every selected manifest before the
first destination write. During publication, retain a durable corpus marker;
`check` and later generation reject that marker until an operator reconciles the
output and deliberately removes it.
Atomic replacement protects one manifest, but a multi-game generation can still
stop between files and leave a mixed authority corpus. The marker makes that
interrupted state visible and prevents a release check from blessing it. Manual
recovery is intentional: automatically deleting the marker cannot know whether a
power loss made every directory entry durable on the current platform. The
production build remains blocked until the complete canonical package corpus is
available and all catalog rows have independently generated artifacts.
Alternatives:
- Publish games one at a time with no corpus marker. Each JSON file is valid,
but a late failure silently leaves a mixed old/new catalog.
- Delete the marker automatically on the next run. This improves convenience,
but hides the exact interrupted state an operator must inspect.
- Write the entire corpus into one file. Replacement becomes atomic at the file
level, but on-demand per-game loading and bounded validation are lost.
## 2026-08-09 — Use the mDNS daemon acknowledgement as its public completion boundary
**TL;DR:** Explicitly unregister advertisements and wait for `mdns-sd`'s
`DaemonStatus::Shutdown` before the owning discovery/advertisement scope
returns. Describe this as the dependency's shutdown acknowledgement, not as a
literal thread join.
The dependency discards its internal daemon-thread handle. Its shutdown status
is sent from that thread at the end of daemon processing, immediately before the
thread closure returns, so the acknowledgement proves that daemon-owned mDNS
state and callbacks have settled. The public API provides no handle for joining
the final thread return itself. The application additionally owns and joins its
outer discovery worker thread; no mDNS callback retains application filesystem
or transaction authority after the daemon acknowledgement.
Alternatives:
- Claim that the status receiver joins the thread. It does not, and that wording
would overstate what the dependency exposes.
- Drop the daemon after merely sending shutdown. This can leave callbacks and
daemon state active beyond the discovery scope.
- Fork `mdns-sd` to retain its thread handle. That yields a literal join, but
adds dependency maintenance for a final return boundary that carries no
application authority after the acknowledged daemon shutdown.
## 2026-08-10 — Persist only active, root-bound install intents
**TL;DR:** An install intent exists only while an install, update, or uninstall
transaction is active or awaiting recovery. Schema 2 binds that intent to the
canonical configured games directory. Startup and `SetGameDir` scan every
persisted intent before mutation and fail closed on foreign, invalid, aliased,
or settled-on-disk state; successful settlement durably removes the intent.
Install recovery can rename or delete `local`, `.local.installing`, and
`.local.backup`, so a per-game record without a root identity could be replayed
against a different configured directory. Treating a corrupt record as absent
would likewise let ordinary cleanup overwrite the only recovery evidence. Every
operation therefore preflights reserved slots, durably publishes a root-bound
active intent before its first filesystem transition, and retains that intent
until the strict recovery table has settled and synced the game root. There is
no schema-1 compatibility path.
The state layout retains one intent path per game ID. This is safe only because
an unresolved intent for another root blocks peer startup, a root change, and a
new same-ID mutation before any cache, quarantine, transfer, or payload state is
changed. Recovery also unions persisted intent IDs with visible game-root IDs,
so an absent current game directory cannot hide an active transaction.
Alternatives:
- Store intents in a namespace keyed by the canonical games root. This would
preserve unresolved transactions for several roots simultaneously, but adds
state discovery, migration, and operator-recovery complexity not needed while
root changes are required to settle the old transaction first.
- Ignore a foreign-root intent when switching directories. A later same-ID
operation could overwrite the sole journal and permanently lose recovery
authority for the old root.
- Persist a settled `None` record. That creates foreign-root ambiguity during a
normal directory switch; absence is the canonical settled representation.
- Treat malformed or legacy intents as missing. That restores availability by
guessing, but can destructively apply cleanup to markerless or partially
transitioned install state.
## 2026-08-10 — Namespace download ownership by canonical games root
**TL;DR:** Keep each game's download ledger and recovery marker under
`games/<game_id>/download_ownership/v1-<root_digest>/`, where the digest is a
domain-separated BLAKE3 hash of the lossless canonical games-directory key.
Retain the full key inside `record.json`, and migrate the former singleton
record into its derived namespace with a copy-first, fsync-before-delete
protocol.
A root binding inside one per-game record prevented cross-root deletion, but it
did not preserve authority: downloading the same ID in root B replaced root A's
settled or pending record and could clear A's recovery marker. Independent
namespaces preserve exact recovery and downloaded-file removal history for both
roots. The digest is only a fixed-length lookup component; the embedded full key
remains authoritative, so a digest/key mismatch is invalid and never eligible
for baseline replacement.
Startup and same-root recovery inspect only the exact selected namespace, while
unrelated root namespaces remain inert. The bounded state inventory still
discovers ownership-only IDs, including an interrupted download whose physical
game root is absent. A current pending absent-root generation settles to empty;
malformed, marker-only, misplaced, or aliased selected state remains
quarantined.
Existing singleton records are migrated as local durable state, not accepted as
a second runtime format. Migration publishes and syncs the namespaced record and
marker before unlinking the singleton. A crash may therefore leave two copies;
the next run completes cleanup only when they are byte-for-byte and marker-state
equivalent. Conflicting or split evidence fails closed without overwriting
either copy. A legacy temporary file without a record or marker is safe scratch
and is swept after no-follow validation.
Alternatives:
- Keep replacing one root-bound slot. This prevents deletion in the wrong tree,
but loses recovery and removal authority for every previously selected root
with the same game ID.
- Reject every foreign settled record before using a new root. This preserves
authority but makes normal multi-root use unavailable indefinitely because a
settled ownership ledger is intentionally retained for later removal.
- Use a root-first global state tree. It gives the same separation, but makes
per-game state inventory, install intent coordination, and migration broader
than the game-first namespace needed here.
- Trust the digest without checking the embedded key. A collision or misplaced
directory could then bind state to the wrong root; the full lossless key is
always revalidated instead.
## 2026-08-10 — Bound peer-CLI stdin cancellation by descriptor or process lifetime
**TL;DR:** On Unix, duplicate stdin before peer startup, preload ordinary files,
and read pipes and terminals through an owned nonblocking Tokio `AsyncFd`.
SIGINT and SIGTERM participate in the same CLI scope as commands and events:
they cancel and settle the command future, await peer shutdown, and drain the
event loop before returning. On non-Unix, Tokio's stdin remains the normal
EOF/command reader, but cancelling its hidden blocking read cannot be made
quiescent under `unsafe_code = "forbid"`; after exceptional event termination or
a shutdown signal, the CLI therefore exits the process only after command-task
bookkeeping, peer shutdown, and event settlement have completed.
The non-Unix process boundary is deliberately narrow. EOF and the JSONL
`shutdown` command finish normally and retain their existing output semantics;
neither path calls `process::exit`. The exceptional event path exits nonzero,
while a handled shutdown signal exits successfully after cleanup. This avoids
returning into Tokio runtime destruction with a blocking stdin worker that can
wait forever for another byte.
Alternatives:
- Abort the Tokio stdin task and return normally. Tokio documents that the
underlying blocking read is uncancellable, so dropping the runtime can hang
while stdin remains open.
- Put blocking stdin on a detached or uninterruptible application thread. That
moves the same lifetime leak outside Tokio and still cannot be joined on every
exit path.
- Add a stdin-proxy child process. It provides a portable kill-and-wait handle,
but adds a helper process and protocol surface to every CLI invocation when
Unix already has a direct safe descriptor solution.
- Call Windows cancellation APIs directly. That requires a separately audited
unsafe boundary and still depends on best-effort device cancellation; the CLI
crate forbids unsafe code.
## 2026-08-10 — Require extracted regular files to be emitted exactly once
**TL;DR:** Catalog publication rejects a regular extracted path repeated within
one RAR or across several root `.eti` archives. Repeated directory entries are
allowed because they carry no bytes and archives commonly restate parents.
The receiver verifies one final path, size, and BLAKE3 value per extracted
entry. Allowing several archive members to write the same regular path would
make the actual extraction order an extra, implicit authority and could hash a
different occurrence from the one promoted at install time. Emit-once regular
files keep the manifest and extraction transaction one-to-one.
Alternatives:
- Let the last archive win. This matches some extractors, but binds authority to
sorting and overwrite behavior that is not represented in the manifest.
- Permit duplicates only when bytes are equal. The publisher would still need to
prove which occurrence the installer emits, adding work with no fixture or
production requirement.
- Reject repeated directories too. This is simpler but excludes ordinary
multi-archive layouts that redundantly list shared parent directories without
creating overwrite ambiguity.
## 2026-08-10 — Keep Phase 2 catalog authority behind one protocol-7 adapter
> **Superseded by the Phase 4 protocol-8 cutover:** the temporary version-only
> adapter was deleted. Peer availability and both transfer requests now carry
> the exact catalog `ContentId`.
**TL;DR:** Build every ordinary download from the receiver's immutable catalog
manifest. Until the coordinated Phase 4 cutover, protocol 7 selects sources only
by exact catalog game version; it does not pretend to advertise `content_id`.
Every received chunk is still checked against the local catalog, and exact wire
content eligibility arrives in the one planned protocol bump.
Protocol 7 has no content-ID field. A partial wire bump would either violate the
one-cutover policy or require a compatibility path that this project explicitly
rejects. The temporary adapter therefore carries the locally expected
`content_id` through planning, ownership, verification, and retry while using
only version-matched protocol-7 peers as candidates. Remote paths, sizes,
chunks, hashes, and majority descriptions have no storage authority.
An integrity mismatch quarantines the runtime-local `(PeerId, content_id)` pair.
The address is deliberately excluded so endpoint rotation cannot clear the
failure, while another content ID remains eligible. Transport failures retry but
do not quarantine, and no result creates durable peer trust. Each failed chunk
may try every distinct eligible peer identity once; terminal failure means that
set is exhausted rather than that a numeric retry counter expired. One absolute
ten-minute deadline covers the complete open/request/receive/check lifetime of
each ordinary chunk and is classified as transport failure.
Ownership schema 2 records the exact committed and pending catalog content IDs.
The no-transfer local shortcut requires settled ownership for the expected
content ID in addition to the catalog-version sentinel. Missing, pending,
legacy, pre-content-ID, recovery-marked, or differently bound state cannot make
local bytes authoritative.
Alternatives:
- Add `content_id` to protocol 7 or ship an intermediate protocol version. This
would improve pre-transfer selection now, but splits the deliberately
coordinated identity/catalog/synchronization cutover and creates an
unsupported intermediate wire design.
- Continue selecting a remote description or file-size majority. That lets the
same peers supplying bytes choose the paths and expected shape, defeating the
bundled catalog authority.
- Persist integrity quarantine. This would survive restarts, but turns one byte
failure into durable trust state without an operator recovery model.
- Quarantine by address or stop after a fixed retry count. Addresses are not
peer identity, while a fixed count can skip a distinct eligible source that
remains available.
- Treat a matching `version.ini` as verified local content. That proves only a
version string and would let unknown or pre-catalog bytes bypass streaming
verification.
## 2026-08-10 — Make Stream Install exact at admission and promotion
**TL;DR:** Offer and accept Stream Install only for catalog games with a
verified extracted-file manifest. The sender must have exactly the catalog's
direct root `.eti` archive set, and the receiver must materialize exactly the
catalog's archive and extracted-output sets before verified staging can be
promoted.
Sender admission loads the same catalog manifest used for ordinary serving,
requires Stream Install support, and compares the direct regular root `.eti`
names with the manifest before starting the extraction provider. This prevents a
raw requester from learning unsupported or extra archive contents merely because
the receiver would later reject them.
The receiver treats sender framing and RAR CRC32 as bounded transport metadata,
not authority. It verifies the complete archive set and every extracted
path/kind/size/BLAKE3 value while writing isolated staging. Missing, extra,
shape-mismatched, repeated, or hash-mismatched regular files fail integrity and
roll back before another source starts. The global emit-once regular-file rule
recorded above removes archive-order overwrite authority; repeated directory
entries remain allowed.
Only after catalog verification does the receiver sanitize and apply account,
language, and persona settings to staging. Promotion to `local/` follows that
rewrite. The one-shot launch-settings marker is written after a successful
commit, so a marker failure cannot bless an unpromoted tree and first play can
safely retry the rewrite.
Alternatives:
- Rely only on receiver rejection. This preserves receiver integrity but lets a
hostile raw requester trigger extraction of unsupported or extra sender
archives.
- Trust RAR CRC32 and sender-declared sizes. The source controls both metadata
and bytes, so this detects accidents without establishing catalog authority.
- Apply launcher settings after promotion. A rewrite failure would leave a live
installed tree that never passed the complete pre-promotion transaction.
- Write the one-shot marker before promotion. A later commit failure could make
first play skip the rewrite for a tree that was never installed.
## 2026-08-10 — Separate fixture authority from the production catalog gate
**TL;DR:** The fixture generator creates reduced catalogs only for development
and acceptance tests. Every non-opted-in build defaults to production resources,
and production packaging cannot proceed until every row in the production
`game.db` has an independently generated and checked manifest.
The test-only `lanspread-fixture-catalog` derives a filtered `game.db` and
companion manifests from explicitly selected fixture packages, using the Rust
manifest implementation rather than scenario-owned hashes. Tauri accepts that
authority only when both the exact checked-in development resource map and
`LANSPREAD_USE_FIXTURE_CATALOG=1` are present. The custom production profile
cannot be downgraded by that opt-in.
All other builds require exactly `game.db`, `manifests/*`, and `assets/*` as
production resources and run the publisher's complete-catalog check before
packaging. The checkout currently contains 186 production catalog rows but not
their canonical packages or generated manifest corpus, so this gate
intentionally fails. Passing fixture checks proves the implementation path, not
production catalog completion.
Alternatives:
- Let ordinary development configuration silently supply fixtures to release
builds. This makes local packaging convenient but can ship test hashes as
production authority.
- Generate production hashes from whatever bytes a runtime peer happens to have.
That makes untrusted local inventory, rather than the canonical package
publisher, the authority.
- Permit a partial production manifest directory. This would package games that
cannot be verified and make catalog availability dependent on missing runtime
artifacts.
- Treat the fixture corpus as production proof. Its reduced rows and synthetic
packages do not cover the real 186-game catalog.
## 2026-08-10 — Make one Ed25519 TLS key the installation identity
**TL;DR:** One installation-local Ed25519 key signs one self-issued certificate.
`PeerId` is lowercase unpadded RFC 4648 base32 of BLAKE3 over the complete
canonical DER SubjectPublicKeyInfo. There is no UUID, address-derived identity,
shared repository key, or alternate identity fallback.
The desktop app owns a strict version-1 `peer-identity-v1.json` record in its
Tauri app-data directory. The JSON stores only the exact version, `ed25519`
algorithm, canonical no-pad base64 certificate DER, and canonical no-pad base64
PKCS#8 private-key DER. Load derives `PeerId` from the certificate SPKI and
requires the key, SPKI, certificate signature algorithm, and exact derived SAN
to agree within fixed record, certificate, and key size bounds. Private key
material is redacted from `Debug` and errors.
File and parent opens reject links and Windows reparse points. Publication uses
a mode-0600, synced temporary file and a no-clobber hard-link winner; a
pre-existing canonical record is never overwritten. Corrupt bytes are preserved
byte-for-byte in a quarantine sidecar before replacement, and retained handles
are used for permission changes and object checks. Read, permission, quarantine,
or publication failure returns a typed `Ephemeral` outcome and a fresh identity
for that runtime. An explicitly selected peer-CLI `--identity-file` is instead
strict and nonmutating: missing or invalid input fails startup without repair,
quarantine, replacement, or generation.
This state has one supported normal app-data owner and deliberately has no
cross-process identity lease. Under that model, no-follow capabilities contain
traversal and avoid following or changing an outside symlink target, while
no-clobber publication preserves an existing winner. A concurrent same-user
writer in the final quarantine check-to-unlink window can still cause
availability or identity-continuity loss; that writer is outside the supported
ownership model.
Alternatives:
- Keep the old UUID beside an unrelated TLS key. That leaves the displayed and
routed identity unauthenticated and permits address or payload substitution.
- Distribute one application certificate and key. Every installation would then
possess the same signing authority, so CertificateVerify could not identify a
peer.
- Add a mandatory OS lock or lease. That expands the portability and recovery
model without a multi-process app-data-owner requirement.
## 2026-08-10 — Pin outbound responders without changing protocol 7
> **Partly superseded by the Phase 4 protocol-8 cutover:** typed endpoint/SPKI
> pinning and TLS hardening remain current. The protocol-7 ALPN, serialized
> identity assertions, and `Goodbye` discussion below are historical; the only
> current ALPN is `lanspread/8`.
**TL;DR:** Every outbound dial consumes `PeerEndpoint { peer_id, addr }`. The
shared s2n client endpoint carries the expected ID through exact SNI, and a
custom rustls verifier checks the full certificate SPKI before delegating the
real TLS 1.3 CertificateVerify signature to rustls.
The rustls provider is the only compiled s2n TLS provider. Both endpoints permit
TLS 1.3 only and bind ALPN to `lanspread/7`. The client disables resumption and
0-RTT. The server has no session store, emits no TLS 1.3 tickets, accepts no
early data, and sends no half-RTT data. Ed25519 is the only advertised signature
scheme. All network, handshake, liveness, download, retry, and Stream Install
callers retain the typed endpoint to the final `QuicConnector::connect` call;
there is no address-only dial seam.
The load-bearing in-process QUIC proof uses these production providers and
connector: the correct key succeeds, certificate A signed by private key B
passes the SPKI check but fails CertificateVerify, a different valid peer at an
exactly reused address fails the expected-ID pin, and two reconnects repeat both
certificate and signature verification. The hostile A/B resolver is test-only.
This is responder authentication, not mutual TLS. Protocol 7 still serializes
its existing string identity fields, and an inbound request's actor or `Goodbye`
assertion is not yet bound to a client certificate. Catalog `content_id`,
inbound authority, and metadata generations change together in the Phase 4 wire
cutover; Phase 3 introduces no intermediate wire mode.
Alternatives:
- Assert CertificateVerify success in the custom verifier. That would accept a
certificate whose public key does not match the handshake signature.
- Configure one verifier per connection. s2n's shared client endpoint does not
expose that seam; exact SNI is the stateless, concurrency-safe carrier.
- Leave resumption enabled. A resumed connection could bypass the per-connect
certificate and signature proof this identity model requires.
## 2026-08-10 — Treat bounded mDNS records as candidates
**TL;DR:** mDNS can propose a canonical `PeerEndpoint`, but it cannot add a
peer, publish UI topology, merge a library, or merge Call to Play history. Only
a current TLS-pinned outbound handshake can commit authenticated state.
The native mDNS browser runs in one joinable worker thread. It uses a bounded
64-entry nonblocking ingress channel; repeated observations are hints and are
coalesced by dropping overflow instead of blocking shutdown or allocating
without bound. The async discovery service also caps active endpoint
negotiations at 64 and lexically drains every started negotiation before the
worker joins.
A candidate reserves an RAII negotiation lease before it is queued or awaited.
The lease owns current claims for both `PeerId` and listener address. Failure,
cancellation, or future drop clears current claims synchronously without an
async database lock. A stale handshake result performs zero endpoint, library,
feature, UI, or Call to Play mutation. Direct CLI connections use the same typed
candidate reservation before their structured child starts.
Alternatives:
- Insert mDNS records directly into `PeerGameDB`. A spoofed advertisement would
become roster and download-source authority before TLS proof.
- Use an unbounded callback channel. A discovery burst could retain arbitrary
memory and delay shutdown indefinitely.
- Reserve inside the handshake future. Queueing or scheduling delay would leave
an older future able to claim authority after a newer observation committed.
## 2026-08-10 — Separate candidate leases from endpoint generations
**TL;DR:** Candidate transitions and refreshes have different precedence, and
every successful authentication receives a fresh endpoint generation. Work that
observed an older generation cannot refresh, remove, or roll state back.
A candidate and a refresh both refuse to reserve while any in-flight negotiation
physically overlaps their peer ID or listener address; neither silently
supersedes the current lease owner. A refresh must also present the exact
authenticated `PeerLivenessSnapshot { endpoint, generation, last_seen }`.
Observing a new runtime session fences the older ticket's commit authority, but
the fenced lease retains physical peer/address occupancy until its owner drops
or releases it. Conditional commit validates both claims before any remote state
mutation and reports same-ID moves and different-ID address eviction.
Ping success updates `last_seen` only for its captured endpoint and generation.
Ping failure and stale pruning remove only that same generation. Removal queues
`PeerLost`, the exact count, and the catalog `ListGames` snapshot while
retaining the database write guard, so a reauthentication cannot publish its
newer `PeerDiscovered` transition between the removal and its UI snapshot.
Handshake commit uses the same topology-critical-section rule.
Alternatives:
- Let every refresh become the latest negotiation. An old endpoint refresh can
then supersede an already observed move or address takeover.
- Key liveness only by peer ID or address. A delayed probe can update or remove
a different authenticated generation after reconnect or address reuse.
- Emit topology after releasing the database lock. Concurrent reauthentication
can invert `PeerLost` and `PeerDiscovered` and pair them with inconsistent
count or library snapshots.
## 2026-08-10 — Cut protocol 8 to responder-owned bounded state pulls
**TL;DR:** Protocol 8 (`lanspread/8`) is the one current wire contract. mDNS and
revision hints are untrusted candidates; all library and Call-to-Play state is
accepted only from a full pull against an exact TLS-pinned `PeerEndpoint` and
committed against its current generation and runtime session. Exact catalog
`ContentId` is required for availability and transfer.
The cut deletes protocol-7 `HelloAck`, library deltas, `Goodbye`, remote file
descriptions, version-only source selection, pushed/acknowledged Call-to-Play
events, union-history healing, and every compatibility shim. `Hello` is an empty
request. Its pinned responder returns only its own `PeerStateSnapshot`, while
`Pong` returns the runtime-session ID and the library and Call-to-Play
revisions. `LibraryChanged` and `CallToPlayChanged` carry the same small
untrusted `ChangeHint`; an unknown claim is ignored and a known claim can only
coalesce a pinned full pull. A dropped hint converges through the next pinned
revision check.
The library domain is a sorted, unique, bounded full slice of
`GameAvailability { game_id, content_id }`. It contains no remote-chosen path,
size, version, file list, or manifest digest. Ordinary chunk requests name the
exact `ContentId`, a `CanonicalCatalogPath`, and one catalog range; Stream
Install requests name the exact `ContentId`, and all path-bearing Stream Install
frames, including archive names, are canonical typed paths. The receiver's
immutable catalog remains the only authority for expected bytes and layout.
Call to Play is a collection of direct per-author slices, never a relayed
ledger. The responder serves only its local author snapshot; the receiver
attributes it to the pinned peer. A typed `CallId` binds creator identity to a
fresh nonce, while the core generates event IDs and timestamps and the snapshot
owns the display name. A higher same-session revision replaces the whole slice;
new sessions clear the old domain, invalid same-session candidates preserve the
last valid slice, and invalid new-session candidates leave it absent.
Participant events remain hidden until a direct creator slice is present.
Unresolved and terminal histories expire as whole calls after five and 15
minutes respectively, without tombstones.
Wire and scheduler limits are part of the authority boundary. Strict fallible
control decoding rejects unknown/noncanonical shapes, v7 frames, extra frames,
oversized frames, and duplicate or unsorted library IDs. The state-sync queue
and tracked-peer table are capped at 64, with at most eight pulls and eight hint
sends in flight. Discovery caps both queued records and negotiations at 64. The
server caps unauthenticated handshakes and established connections at 64, caps
each connection at 32 control streams, accepts exactly one bounded request frame
plus EOF, and drains all started work on shutdown.
This decision fully supersedes “Bound protocol-7 download descriptions
generously” and “Keep Phase 2 catalog authority behind one protocol-7 adapter.”
It supersedes only the protocol/ALPN and removed-message clauses in “Pin
outbound responders without changing protocol 7,” “Own shared QUIC endpoints up
to the provider boundary,” “Isolate the peer runtime behind a joinable
supervisor thread,” and “Await fanout and directory transitions through
publication”; their endpoint-authentication, structured-lifetime, and directory
admission decisions remain current.
Alternatives:
- Retain protocol 7 as a fallback. This would create a second authority model
and contradict the current-only protocol policy.
- Put revisions or state in mDNS. The advertiser is static after registration,
and an unauthenticated record must never become roster or state authority.
- Push deltas or Call-to-Play events directly. This multiplies ordering,
acknowledgement, relay, and healing paths that a coalesced full replacement
avoids.
- Dial by address or accept author IDs from payloads. Either would separate the
data authority from the identity proven by the responder certificate.
- Reuse a repository certificate and private key. Every installation would
possess the same signing authority and could not authenticate a peer identity.
## 2026-08-10 — Index catalog content identity without loading manifest bodies
**TL;DR:** Every packaged catalog has one mandatory compact, exact-coverage
content index. Remote availability can be joined to local `ContentId` and Stream
Install policy without disk access, while every lazily loaded manifest must
reproduce the indexed values.
`game.db` remains the catalog ID/version authority. Its sibling
`manifests/catalog-content-index-v1.jsonl` contains exactly one entry for every
database row: exact game ID, exact version, catalog `ContentId`, and whether the
manifest has catalog-owned Stream Install output. The index is bounded,
canonical, portable-alias safe, and eagerly read once when `CatalogBundle` is
constructed. Missing, extra, version-skewed, malformed, noncanonical, linked, or
oversized index artifacts fail construction.
Full manifest bodies remain on demand. Loading any body revalidates its complete
structure and recomputed content ID, then requires both that ID and its derived
Stream Install capability to equal the immutable index entry. There is no
fallback to body parsing when the index lacks an ID. Tauri and peer-CLI remote
availability joins use only this non-I/O identity lookup; body consumers such as
sender admission, download planning, and Stream Install verification retain the
full manifest path. Disk-backed bundle loads and full-corpus validation check
the durable publication marker both before and after filesystem work; the
explicit cache-only lookup remains an immutable, non-I/O snapshot.
The publisher prepares and independently reproduces every selected package
before taking the durable publication marker. Once it owns that create-new
writer exclusion, it freshly loads the current index and validates every
unselected body against it before deriving a mixed index. It then writes the
selected bodies, atomically writes the complete index, validates the resulting
corpus, and removes the marker last. Full generation derives every entry from
the newly prepared bodies. Incremental generation requires an existing exact
indexed corpus and cannot bootstrap a partial catalog. Production build
validation still loads every body and proves that the database, index, and body
corpus agree exactly.
Alternatives:
- Parse a manifest body for every remote availability. A bounded authenticated
peer set could repeatedly force synchronous parsing and monotonic cache growth
in UI and CLI adapter paths.
- Preload every catalog manifest at peer startup. The accepted manifest bounds
make total decoded memory far larger than the compact identity data actually
needed for availability joins, and it would reverse the deliberate lazy-body
contract.
- Treat a missing index entry as permission to load the body. That creates a
second authority path and makes exact coverage unenforceable.
+22 -14
View File
@@ -2,7 +2,18 @@
## Status
Revised implementation plan; not yet implemented.
The code and local test surface described by Phases 1-5 are implemented in this
checkout. The standard Rust, Tauri, and frontend gates pass, and the current
fresh-image S1-S49 Docker matrix is recorded in
`organize/testing/PEER_CLI_SCENARIOS.md`. This is not yet full
production-release acceptance because three external evidence gates remain:
- the canonical 186-game package corpus and production manifests are absent, so
the production catalog and bundle gates remain fail-closed;
- no run on a supported Windows/NTFS system yet proves reparse-point confinement
and file/directory durability; and
- no representative physical-LAN three-peer and throughput run has been
recorded. Docker-host throughput is local acceptance evidence only.
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
@@ -48,7 +59,8 @@ bundled content manifest -> validated local download plan
-> version.ini commit only after complete success
local Call to Play change -> cheap invalidation hint to known peers
-> each peer pulls the author's current state over pinned TLS
-> each peer pulls the author's current state
over pinned TLS
pinned liveness ping -> responder's own current revisions
-> pull that one responder only on mismatch
@@ -99,8 +111,8 @@ The following are explicit non-goals:
### 3.1 Confine download preparation first
This remains the first implementation task because it fixes a live local
data-loss path without depending on authentication or a wire change.
This was implemented first because it fixed a live local data-loss path without
depending on authentication or a wire change.
The peer core constructs a `ValidatedDownloadManifest` before
`begin_version_ini_transaction`, `prepare_game_storage`, directory creation,
@@ -136,13 +148,10 @@ 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.
Historically, this validator contained protocol-7 remote descriptions through a
narrow adapter that required and removed exactly one matching leading `game_id/`
component. The current protocol constructs the same validated type directly from
the bundled content manifest; remote descriptions no longer define local paths.
Required proof includes hostile descriptors placed after valid descriptors,
cross-game paths, both requested and other-game `local/` sentinels, reserved
@@ -459,9 +468,8 @@ Do not ask the user to solve a cryptographic implementation problem.
## 4. One protocol cutover
Develop the pieces behind internal APIs, then replace protocol 7 with one new
current protocol (protocol 8 if the version has not moved). Do not ship
intermediate protocol 8/9/10 designs and do not add compatibility decoding.
The coordinated cutover replaced protocol 7 with current protocol 8. There is no
intermediate 8/9/10 design and no compatibility decoding.
The cutover includes:
@@ -1,5 +1,12 @@
# Finish Call to Plays replication seam
> **Historical / superseded:** This plan describes the removed protocol-v7
> client-authored, relayed event and acknowledgement model. The current protocol
> accepts local intents, generates identity and time fields in the core, pulls
> author-owned snapshots directly, and publishes full replacement views. Retain
> this document only as design history, not current architecture or acceptance
> guidance.
## Summary
Keep the existing architecture: immutable events, deterministic reduction, live
@@ -1,5 +1,11 @@
# Call to Play review — Fable 5 (xhigh)
> **Historical / superseded:** This review assesses the removed protocol-v7
> client-authored, relayed event and acknowledgement model. The current protocol
> accepts local intents, generates identity and time fields in the core, pulls
> author-owned snapshots directly, and publishes full replacement views. Its
> conclusions and test counts are not current acceptance evidence.
## Verdict
The plan is faithfully implemented — all five commits match the planned
@@ -119,8 +125,9 @@ 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.
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,5 +1,14 @@
# Call to Play Code & Architecture Review Report
<!-- This historical review preserves its original HTML table and flow lines. -->
<!-- rumdl-disable MD013 MD033 -->
> **Historical / superseded:** This review assesses the removed protocol-v7
> client-authored, relayed event and acknowledgement model. The current protocol
> accepts local intents, generates identity and time fields in the core, pulls
> author-owned snapshots directly, and publishes full replacement views. Its
> conclusions are not current architecture or acceptance evidence.
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)
@@ -1,5 +1,11 @@
# Review: Call to Play fix series (`872692e..2c204ac`)
> **Historical / superseded:** This review assesses the removed protocol-v7
> client-authored, relayed event and acknowledgement model. The current protocol
> accepts local intents, generates identity and time fields in the core, pulls
> author-owned snapshots directly, and publishes full replacement views. Its
> conclusions are not current architecture or acceptance evidence.
## 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
+6
View File
@@ -1,5 +1,11 @@
# Fable 5 findings
> **Historical / superseded:** These findings assess the removed protocol-v7
> shared-certificate, client-authored, relayed event model. The current protocol
> authenticates typed peer identities, accepts local intents, generates event
> authority in the core, and pulls author-owned snapshots directly. Retain this
> document only as historical review context, not a current issue list.
Review of the issues Fable found after the Call to Play follow-up fixes. This
document records the assessment only; it is not an implementation plan.
+4 -4
View File
@@ -28,10 +28,10 @@ The previous four findings have landed in code and tests:
startup recovery remove only exact downloader-owned paths and preserve unknown
root files, instead of leaving crashed partial archives or broadly deleting
the game root.
- `update_game` now uses `PeerCommand::FetchLatestFromPeers` to skip local
manifest serving and fetch fresh peer metadata. Covered by
`update_fetch_emits_fresh_manifest_from_latest_peer` and
`update_request_skips_local_manifest_even_when_download_exists`.
- Historical, superseded by the protocol-v8 cutover: `update_game` once used
`PeerCommand::FetchLatestFromPeers` and fresh peer metadata. That command and
its cited tests no longer exist; updates now use the local catalog manifest
and authenticated peers advertising its exact `ContentId`.
- Download-to-install handoff no longer relies on `OperationGuard::Drop` for
ordered state transitions. Covered by
`download_handoff_waits_for_readers_and_auto_installs` and the liveness