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)
74 KiB
Peer authentication refactor decisions
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
ContentIdrequests 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
in aggregate for one game. Resolved destinations are capped at 1,000 platform
path units.
The plan requires count, per-file, and aggregate limits but intentionally does not prescribe values. These limits are far above the expected game and CLI fixture sizes while bounding allocation, planning work, and arithmetic.
Alternatives:
- Lower product-sized limits would reject abusive inputs earlier, but risk inventing constraints that real large games or mod packs exceed.
- Limits near
usize/u64maxima avoid practical compatibility concerns, but do not meaningfully bound memory or work. - Configurable limits add settings and test combinations without a current user need; protocol safety limits should be consistent between installations.
2026-08-09 — Track download ownership; never infer it from unknown files
TL;DR: Persist an atomic per-game set of successfully downloaded paths under application state. On replacement, remove only paths in the previous owned set that are absent from the new authoritative manifest. If old or corrupt state has no trustworthy set, preserve unknown root files.
The existing game directory has no provenance information. Deleting every non-reserved path would remove stale downloads, but could also delete a user's own files and contradict the plan's preservation rule. The first update after upgrading may therefore leave legacy stale files; subsequent successful downloads have exact ownership and can clean safely.
Alternatives:
- Bootstrap ownership from the current directory. This is seamless, but can permanently misclassify user files as Lanspread-owned and later delete them.
- Delete every non-reserved file missing from the new manifest. This is simple and cleans legacy state completely, but is unsafe for user files.
- Delete only familiar archive suffixes such as
.eti. This avoids most user files, but bakes package naming guesses into the security boundary and cannot clean general catalog payloads. - Never clean stale files. This is safest for user data, but fails the explicit successful-replacement requirement and can keep serving obsolete payload.
2026-08-09 — Generate production manifests outside the source checkout
TL;DR: Provide a small deterministic generator binary that runs next to the canonical production packages and writes the JSON manifest artifacts that are then stored and packaged with Lanspread.
This checkout has catalog metadata for 186 games but canonical package bytes only for a small CLI fixture set. Hashes cannot be derived honestly without the bytes. Keeping the generator here makes the format reproducible without requiring production game packages in Git.
Alternatives:
- Store production packages in this repository. That would make generation self-contained, but is impractical for size and distribution reasons.
- Generate hashes at application runtime. This would make local peer bytes the authority, defeating catalog-owned verification and adding startup work.
- Accept hashes announced by peers. This is easy to deploy, but provides no protection against a peer that supplies both the bytes and their claimed hash.
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.
The application can switch games directories while retaining the same state
directory. Keying ownership only by game_id would let provenance learned in
one tree delete an unrelated same-named file in another tree. The opaque key is
derived from the platform-native canonical path representation so non-Unicode
paths do not need a lossy conversion.
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 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.
2026-08-09 — Journal pending download ownership across crashes
TL;DR: Persist both the last committed file set and a write-ahead pending
set. Park the old version.ini before recording pending ownership, commit the
new sentinel only after transfer and stale cleanup, then finalize the ledger.
A committed-only ledger cannot distinguish a brand-new partial download from
user files after a crash. The pending set makes cancellation and recovery exact.
Parking the old sentinel first removes an ambiguity during recovery: while a
pending set exists, a regular version.ini can only be the newly committed
sentinel. A crash before the pending write instead leaves the parked sentinel,
which recovery restores.
Alternatives:
- Record pending before parking the old sentinel. This minimizes time without a root sentinel, but recovery cannot tell an old sentinel from a landed commit.
- Keep only committed ownership. This is smaller, but leaks partial files from a crashed first download because they have no trustworthy provenance.
- Put every downloaded file through a separate staging tree. This gives a clean promotion point, but can require another full game's worth of disk and a large multi-file transaction mechanism.
2026-08-09 — Reject untracked exact manifest targets
TL;DR: Before parking version.ini or preparing storage, reject a download
when a regular-file destination already exists but is absent from the last
committed ownership set. Previously owned paths may be replaced; every unknown
path remains untouched.
Without prior ownership state, an existing file at an exact manifest target is ambiguous. Automatically claiming it would make a failed first or post-upgrade download truncate and then delete potentially user-owned bytes. Failing closed preserves the file, but a legacy tree without an ownership journal may require the user to move or remove conflicting package files before its first update. Phase 2 can later recognize intact catalog bytes by their trusted local hashes without weakening this boundary.
Alternatives:
- Treat every validated manifest target as download-owned. This keeps legacy upgrades seamless, but can destroy an unrelated user file on failure or cancellation.
- Snapshot and restore untracked targets. This preserves their bytes, but adds unbounded backup space and another crash-consistent transaction.
- Stage every payload in a second game tree and promote it atomically. This avoids touching collisions during transfer, but can require another full game's worth of disk and a cross-platform multi-file promotion protocol.
2026-08-09 — Ownership-record rename is its publication point
TL;DR: Once an ownership-record temporary file has been synced and renamed,
the application treats it as published. A subsequent parent-directory sync
failure is returned as a distinct NeedsRecovery outcome: callers never roll
back as though publication failed, and a downloader stops before payload
mutation.
Rolling back after the rename could restore the old version.ini beside a
visible pending record. Recovery would then mistake that old sentinel for the
new download's commit point. Continuing after a failed directory sync is also
unsafe: power loss could retain the previous baseline record and later restore
the old sentinel over mutated payload. The phase-aware result keeps every
observable state unambiguous and prevents both mistakes.
Alternatives:
- Log the post-rename sync failure and continue. This avoids disrupting a download for a rare filesystem error, but permits mutation without a durable write-ahead record.
- Try to rename the old record back after a sync failure. That adds another fallible mutation and can still leave either name visible after a crash.
- Return one ordinary error for every failure. This is simpler, but callers cannot tell whether restoring the old sentinel is safe after the canonical record name became visible.
2026-08-09 — Reject cross-version portable path aliases
TL;DR: An update is rejected before mutation if an owned path changes only
by a portable alias, such as Data.eti to data.eti.
On a case-insensitive filesystem, deleting the old spelling after transfer can delete the newly written destination. On a case-sensitive filesystem, keeping both spellings leaves stale package data. Rejecting the ambiguous transition is rare, deterministic, and safe on every supported filesystem.
Alternatives:
- Compare exact paths only. This correctly removes the old path on Linux, but can delete current data on Windows and default macOS filesystems.
- Treat alias-equivalent paths as identical everywhere. This protects case-insensitive filesystems, but leaks the old spelling where both names can coexist.
- Detect filesystem case behavior and rename through a temporary name. This can support case-only catalog changes, but adds another crash-consistent mutation protocol for a catalog shape the publisher can avoid.
2026-08-09 — Ownership cleanup preserves all directories
TL;DR: The download ledger authorizes deletion of exact regular files only; cleanup does not prune their now-empty parent directories.
An empty directory may have existed before Lanspread placed an owned file in it, and the current ledger cannot prove directory provenance. Leaving an empty directory is harmless, while deleting a user-created directory violates the fail-safe ownership boundary.
Alternatives:
- Delete every empty ancestor of an owned file. This keeps roots tidy but can delete a user-owned empty directory.
- Journal directory ownership and creation state. This supports exact pruning, but expands the crash protocol and still needs a policy for pre-existing manifest directories.
2026-08-09 — Remove downloads as an empty ownership generation
TL;DR: “Remove downloaded files” parks version.ini, durably journals an
empty pending generation, removes only the last committed file set, discards the
sentinel, and keeps a valid empty ownership record. It never recursively deletes
the game root.
The existing pending-generation recovery already gives this operation a clean crash protocol. Before the empty generation is durable, recovery restores the sentinel and no payload has been touched. Afterwards, recovery idempotently finishes deleting only proven-owned files and finalizes the empty record. Unknown files and directories remain. A legacy, corrupt, or wrongly bound record fails closed because paths and sizes alone cannot distinguish package bytes from user bytes; Phase 2 can later adopt an intact legacy tree by checking it against trusted catalog hashes.
Alternatives:
- Recursively delete the game root, then clear the ledger. This frees every byte, but destroys unknown files and leaves stale deletion authority if the process crashes between those operations.
- Delete the sentinel and leave every ambiguous payload file. This preserves user bytes, but reports a misleading successful removal while reclaiming almost no space.
- Infer ownership from the current remote manifest or filename extensions. This is convenient for legacy trees, but lets untrusted or incomplete metadata authorize deletion.
2026-08-09 — Ownership follows committed paths, not inode generations
TL;DR: A successfully committed download path remains downloader-owned until a later download or explicit removal releases it. The ledger does not persist platform-specific inode or file-ID generations.
This is enough for the stated remote-peer threat model: a peer cannot replace a victim filesystem object except through the one admitted download operation, and untracked exact targets are rejected before that operation starts. A local actor who replaces an already owned path makes the replacement subject to later owned-path cleanup. That local-filesystem race is outside the plan's threat model, but the consequence is recorded because the provenance boundary is path based rather than object based.
The same rule applies if someone deletes and recreates the entire game root at the same configured path outside Lanspread: the last committed relative paths remain owned. The application cannot distinguish that replacement without a separate root-generation marker or platform file identity.
Alternatives:
- Persist inode/file-ID generations and delete only the exact recorded object. This detects replacement, but creates a platform-specific schema and does not survive ordinary copy/restore workflows consistently.
- Hash every owned file before cleanup. Phase 2 catalog hashes can identify intact package content, but always rereading multi-gigabyte payloads solely for deletion adds significant latency and still needs a policy for modified downloader-owned files.
- Never delete a previously owned path automatically. This preserves every local replacement but makes stale cleanup and “Remove downloaded files” unable to reclaim ordinary package data.
2026-08-09 — A baseline record distinguishes new and legacy scratch
TL;DR: Before parking an existing version.ini, the new downloader durably
writes at least an empty ownership record. Recovery restores a parked sentinel
only when that valid baseline exists; scratch beside a missing or invalid record
is discarded without making payload bytes ready.
Older builds used .version.ini.discarded but could leave it behind after they
had already truncated or overwritten payload files. The scratch filename alone
therefore cannot prove that mutation never began. The baseline makes every park
performed by the new transaction format distinguishable.
Alternatives:
- Restore every discarded sentinel when no journal exists. This preserves a clean crash between park and journaling, but can advertise a legacy partial payload as a complete game.
- Never restore a discarded sentinel. This is fail-safe for upgrades, but would unnecessarily discard a known-clean sentinel after a new-format pre-journal crash.
- Introduce another dedicated phase-marker file. This is equally expressive, but adds a second persistent transaction artifact where an empty valid ledger already provides the needed proof.
2026-08-09 — Treat the configured games directory as the capability root
TL;DR: Canonicalize the user-selected games directory once, open the game as one direct non-link child, and perform every download, sentinel, and ownership mutation relative to that retained directory handle. The configured directory itself is the trust anchor; existing links in its absolute parent path are not re-walked and rejected.
This matches the product setting: the user chooses one library directory, while remote descriptions control only descendants of a known catalog game. Rejecting the game root and every descendant link/reparse component closes the peer-driven escape without redefining whether the configured library path may itself be a platform alias or mounted location.
Alternatives:
- Reject every link in every absolute ancestor of the configured directory. This is stricter, but rejects common user-selected paths and requires platform-specific absolute-path walking outside the remote peer's authority.
- Keep one capability from application startup through every settings change. This minimizes ambient path resolution, but considerably expands lifecycle and settings coordination for no additional peer-controlled path component.
- Revalidate strings before each ordinary path operation. This is simpler, but remains vulnerable to check-then-use swaps and reparse behavior.
2026-08-09 — Retain one root handle and reopen each transfer file safely
TL;DR: Keep one capability handle for the game root, but open each directory component and final file without following links for preparation, each chunk, durability checks, and cleanup. Do not retain a handle for every manifest file.
A manifest may contain 100,000 entries. Retaining all file handles would make descriptor exhaustion part of ordinary planning. Reopening from the stable root keeps resource use bounded; each chunk writes and verifies through the exact handle it opened, so a later path swap cannot redirect that write.
Alternatives:
- Retain every destination handle for the full download. This gives the strongest object identity, but can exhaust process and system handle limits.
- Retain one handle per active chunk. This is feasible, but still needs the same no-follow reopen walk and does not simplify preparation or recovery.
- Use absolute paths after initial validation. This uses fewer abstractions, but reintroduces the link/reparse race the confinement phase exists to remove.
2026-08-09 — Do not overstate Windows power-loss durability
TL;DR: Sync payload and sentinel file handles on every platform and sync directory handles where the safe Rust platform API supports it. On Windows, retain the unambiguous process-crash recovery protocol but leave power-loss durability as an explicit real-NTFS gate rather than claiming proof from Linux.
Rust does not provide a portable guaranteed directory flush, and this crate
forbids unsafe code. Silently calling a Unix-only directory sync_all
equivalent would turn an unverified assumption into a false cross-platform
guarantee. The Phase 1 Windows gate therefore still needs a supported Windows
run covering reparse points, rename recovery, and actual filesystem behavior.
Alternatives:
- Add a small platform-specific safe wrapper crate around Windows directory
handles and
FlushFileBuffers. This may establish stronger durability, but it adds native code and still requires real NTFS failure evidence. - Fail every Windows download because directory durability is not portable. This is fail-closed but makes a supported product platform unusable.
- Treat successful file sync and rename as proven power-loss durability. This is convenient, but is not evidence and directly violates the plan's reporting boundary.
2026-08-09 — Do not make hard-link identity part of remote confinement
TL;DR: Reject links and reparse points that can redirect path resolution, but do not reject an otherwise regular manifest target merely because it has multiple hard links. The threat model excludes an attacker controlling the victim filesystem, and only previously recorded download-owned targets may be replaced by a transaction.
A hard link cannot be selected or created by a remote description outside the validated game-relative namespace. A local user can make an already owned inode visible under another name, but that is local filesystem manipulation rather than a peer-controlled path escape. New untracked manifest targets are rejected before mutation instead of being claimed automatically.
Alternatives:
- Reject every existing destination whose link count exceeds one. This better protects local aliases, but can block legitimate deduplicated or legacy game trees and needs consistent evidence on every supported filesystem.
- Copy an existing multiply linked file to a private inode before mutation. This preserves the other name, but silently consumes space and adds another fallible pre-transfer mutation.
- 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
Unpackerand every install primitive to accept only handle-relative capabilities. This is the strongest design, but externalunrarrequires 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
notifyand 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
Goodbyedelivery 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-platformto 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_blockinghandle 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
Goodbyechild 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_placeor 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:
SetGameDiradmission 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-sdto 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
Nonerecord. 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_idto 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.inias 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
Goodbyediscussion below are historical; the only current ALPN islanspread/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
PeerLostandPeerDiscoveredand 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.