Files
lanspread/organize/decision-tracking/PEER-AUTH-REFACTOR-DECISIONS.md
T
ddidderr 0fbf589dc5 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)
2026-08-10 14:05:26 +02:00

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 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 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/u64 maxima 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.

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 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.