docs(plan): record peer-auth decisions and evidence
Record the completed protocol-8 implementation, its security and lifecycle decisions, and the final local acceptance evidence. Mark protocol-7 Call to Play relay reviews as historical so they cannot be mistaken for current design. Keep production acceptance honest by recording the unavailable canonical 186-game manifest corpus, real Windows/NTFS confinement and durability proof, and representative physical-LAN evidence as external release prerequisites. Test Plan: - `just fmt` (passed) - `just test` (passed; 708 workspace tests, including peer 480 and Tauri 56) - `just clippy` (passed) - `just frontend-test` (passed; 91/91) - `just build` (passed; fixture-backed no-bundle build) - `LANSPREAD_S37_MIN_MIB_PER_S=100 just peer-cli-tests` (passed; S1-S49) - `git diff --cached --check` (passed)
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user