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