docs(meta): D-254 — standalone Atlas companion app (make atlas, dual-connection reader) (T-1129)

Six sections: connection model (attach SR_PORT/9876 ~500ms else spawn --port 0 + LISTENING parse, auto-attach-else-spawn); Reader connection class (ConnectionRole on StartupMessage, serde-default Player; no character spawn, NO ObserverSnapshot, permitted-message matrix, drop-not-disconnect, 0-1 Player + 0-N Readers, Player-only shutdown-on-disconnect); app shell (dedicated atlas_standalone.tscn, trivial make atlas target); data browser (separate implant/browser app per Jeroen, six registry entities v1, WIRE-ONLY extending the T-949 precedent — no client SQLite); save/load seam (seed picker now, save picker slot Phase 5+); trading seam (TradingReader superset, idempotency tokens, loopback-only auth assumption recorded). Tyre (lead) + Oscar (connection sections, his activation). Validate + sync clean (388 records, 0 broken).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 08:05:57 +02:00
co-authored by Claude Fable 5
parent 749ba49181
commit b57805d0c6
2 changed files with 111 additions and 1 deletions
+1
View File
@@ -319,6 +319,7 @@ line in place — keep the Q-record for the audit trail rather than deleting it.
- [D-251: Character asset route, 2026 reconfirmation — Quaternius rig, in-house wardrobe, purchased animation tiers](decisions/content.md#d-251-character-asset-route-2026-reconfirmation--quaternius-rig-in-house-wardrobe-purchased-animation-tiers) — _content_
- [D-252: Facing is view-only — movement no longer writes Facing; NPC gaze is intent](decisions/architecture.md#d-252-facing-is-view-only--movement-no-longer-writes-facing-npc-gaze-is-intent) — _architecture_
- [D-253: Region transient state model — seasonal/tidal/weather/snow phase functions (resolves Q-105)](decisions/architecture.md#d-253-region-transient-state-model--seasonaltidalweathersnow-phase-functions-resolves-q-105) — _architecture_
- [D-254: Standalone Atlas companion app — `make atlas`, dual-connection reader](decisions/architecture.md#d-254-standalone-atlas-companion-app--make-atlas-dual-connection-reader) — _architecture_
## Open questions
+110 -1
View File
@@ -2053,4 +2053,113 @@ Technical foundation decisions that constrain implementation: engine, client-ser
---
*107 decisions (D-001 through D-253, excluding gaps). Last updated: 2026-07-08 (D-253 — region transient state model: four clock-terms (diurnal/tidal/weather/seasonal), memoized-by-bucket absolute-clock evaluation, edge-fuzzed per-tile realization; resolves Q-105).*
### D-254: Standalone Atlas companion app — `make atlas`, dual-connection reader
- **Date:** 2026-07-17
- **Decision:** The implant Atlas (D-169/D-170's `implant/map` app) ships as a **second, independent Godot entry point**`client/scenes/atlas_standalone.tscn`, launched via a new `make atlas` target — that boots the SAME implant scene tree used in-game but skips the player entirely: no character, no `main.tscn`, no gameplay HUD. It connects to the simulation server either by **attaching** to an already-running game (inheriting that world's state read-only) or by **spawning** its own server process (offering seed selection now; save selection is a recorded, unbuilt hook — saves are Phase 5+). The server enforces read-only **server-side** via a distinct `ConnectionRole` on the handshake (`Player | Reader`, Reader spawns no character and receives no `ObserverSnapshot` at all) — the Atlas app itself gains no new client capability, it is the existing Atlas UI pointed at a bridge connection the server structurally refuses inputs from. A future market-trading widening (§6) adds a `TradingReader` role as a strict superset of `Reader` (never a replacement) rather than inventing a second connection type.
**(1) CONNECTION MODEL — attach vs. spawn, discovery.**
**Today's reality, confirmed in code:** `main.rs` binds a `TcpListener`, prints `LISTENING:{port}`, then calls `listener.accept()` **exactly once** — blocking, no loop. A second TCP client completes its TCP-level handshake (kernel backlog accepts it) but never gets an application-level accept — it hangs forever waiting for `HandshakeMessage`. **Not refused, not replaced — silently starved.** This is the actual failure mode the reader connection must design against; zero multi-connection plumbing exists anywhere in `server/src/bridge/` today, confirming the ticket's own framing ("almost certainly single-connection").
Default port `9876` (`sim_bridge.gd:26`, matches `main.rs` fallback), overridable via positional addr / `--port` / (client-side) `SR_PORT`. `SR_PORT` is already the env var two Godot scripts read today for "which port do I dial" (`visual_capture.gd:99`, `locomotion_sandbox.gd:67`) — the discovery mechanism reuses that existing convention rather than inventing a third (`SR_ADDR` is a server-side bind override and is not load-bearing for either connection mode below).
- **Attach-mode discovery:** fixed default port 9876 + `SR_PORT` override — the same two-tier scheme the game client already uses to find its own server. Raw TCP connect with a ~500ms timeout (localhost, not WAN — no reason to wait longer). `ECONNREFUSED` is a real, unambiguous signal ("no server listening") and falls through to spawn-mode. A connection succeeding does not yet mean attach is *safe* — that gate is the Reader-role handshake in §2, not the TCP connect itself.
- **Spawn-mode lifecycle:** reuse the `tests/run-visual` precedent exactly — `--port 0` (OS-assigned), parse `LISTENING:{port}` from stdout — but **without `--test-mode`**: the companion needs the real `systems.db` world, not Gauntlet test fixtures. Ownership: the companion app owns the child process it spawns, the same pattern `server_process.gd` already implements (`OS.create_process`/`OS.kill`/`NOTIFICATION_PREDELETE` safety net) — reused directly, not reimplemented. World seed is passed via the companion's own `StartupMessage.world_seed` post-handshake (not a `--seed` CLI flag) — this keeps the save/load seam (§5) as the single source of truth for how a spawned world gets populated, rather than splitting seed-selection across a CLI flag and a wire message.
- **Mode selection UX:** auto-attach-else-spawn — try attach for ~500ms, fall through silently to spawn on refusal. Zero friction for the common case ("inspect the world I already have running"), and the fallback is never wrong (spawn always works). An explicit Attach/Spawn chooser is deferred — only justified if reader-mode failures turn out confusing enough in practice that users need visibility into *why* attach didn't happen; not assumed necessary at design time.
**(2) READER CONNECTION CLASS — handshake variant, server-side enforcement.**
Enforcement is **server-side at the protocol layer**, never client politeness — a hostile or buggy companion client is exactly D-010's adversarial case, and the read-only guarantee has to hold against that, not just against a well-behaved reference client. The one seam that matters: before the server unconditionally spawns a `PlayerCharacter` (`main.rs`, today unconditional on every accepted connection).
**Handshake extension:** add `role: ConnectionRole` to `StartupMessage` — enum `Player | Reader` (widened by §6 to `Player | Reader | TradingReader`) — with `#[serde(default = "ConnectionRole::player")]` for back-compat, rather than a separate pre-startup negotiation message. This follows D-192's existing "no lockstep negotiation" precedent (protocol_version field dropped for the same reason): role is **data on the existing message**, not a new protocol gate. Critical determinism guard: a Reader's `world_seed` field is **ignored server-side and never re-seeds `SimRng`** — a second StartupMessage touching `SimRng` after tick 0 would break determinism for whatever Player is already in session (spawn-mode readers get their seed from the world THEY spawned, at genuine tick 0; attach-mode readers must never be able to perturb an already-running world's RNG state via their own handshake).
**Server enforcement — structural, not filtered:** Reader role skips the character-spawn path entirely **and receives no `ObserverSnapshot` at all** — not a stripped/redacted one, none. This is the load-bearing point: `ObserverSnapshot` is a per-character observation record (facing, inventory, visible_tiles are all meaningless without a character), so forwarding the Player's own snapshot to a Reader — even filtered — would be a direct D-010 boundary violation (a second observer silently granted the first observer's fog-cleared view). What a Reader *can* legitimately receive is proven by the existing handler signatures: `handle_star_map_request`, `handle_city_names_request`, `handle_atlas_request` (and this record's new browse-request handlers, §4) all take **no observer/character/query parameter whatsoever** — just `body_id`/`world_seed`/`path` — which is the independent proof that this data was already install-static/world-public before D-254, not a new carve-out invented for readers.
| Message | Player | Reader |
|---|---|---|
| `Vec<PlayerInput>` (inputs) | yes | **no** |
| `ObserverSnapshot` (outbound) | yes | **no — not even filtered** |
| Atlas/StarMap/CityNames/Browse request+response | yes | yes |
| `HandshakeMessage` | yes | yes |
**Violation handling:** a Reader sending `Vec<PlayerInput>` is syntactically valid (the existing `decode_inbound` demux parses it fine) but role-disallowed. Log + drop on first offense, mirroring the existing recoverable `DeserializationWithDump` pattern; escalate to disconnect only on repeated violations — a natural fit for the already-flagged N-consecutive-errors handling in the bridge module, made per-connection once multiple connections exist.
**Multi-connection architecture — scoped honestly as 0-1 Player + 0-N Readers**, explicitly NOT general N-player (that is D-009's separate, larger, and currently out-of-scope ambition — this record does not reopen it). `BridgeResource` (today a single `Box<dyn SimBridge>`) becomes a collection; the single blocking `accept()` becomes a non-blocking accept-loop polled per-tick, so a Reader connecting mid-session never stalls the Player. The inbound drain loop routes `Vec<PlayerInput>` only from the Player-role connection; atlas/starmap/citynames/browse requests are accepted from any connection, but responses need a connection-id tag (today's response buffers have no "whose request was this" notion, because there has only ever been one connection). Outbound `ObserverSnapshot` sends target the Player connection only — this is a structural enforcement of the boundary above, not merely a convention that could be gotten wrong by a future edit.
**Back-pressure/lifecycle — the sharp existing edge:** today `BridgeError::Disconnected` sets `ServerRunning = false` and kills the **whole server process**, because currently one connection's disconnect *is* the session ending. That behavior must NOT fire on a Reader's disconnect once roles exist — only a Player disconnect should flip `ServerRunning`; a companion app closing its window must never kill the game it's attached to. Determinism holds by construction as long as reader frames never reach the InputQueue/SimRng path (guaranteed by the enforcement above, not by a separate check). Recommend a lower per-reader inbound frame cap (e.g. 8/tick vs. the existing Player cap of 64/tick) — a reader has no legitimate reason to send that volume of requests per tick, and the cap is cheap insurance against a runaway/misbehaving companion client.
**(3) APP SHELL — how `make atlas` launches the Atlas standalone.**
**Decision: a dedicated entry scene, not a feature flag on `main.tscn`.** `client/scenes/atlas_standalone.tscn` is a bare root (`Node2D` or `Control`) with a script (`atlas_standalone.gd`) following the exact boot shape `client/tests/visual_capture.gd` already establishes for minimal Godot entry points (`_init() -> _run.call_deferred()`, connect, wait for handshake, open UI) — except `atlas_standalone.gd` is a real scene script (`extends Node2D`, normal `_ready()`), not a `SceneTree`-extending test harness; the `SceneTree` pattern is for offscreen capture tooling, the standalone app needs a visible window.
Why not a flag on `main.tscn`/`main.gd`: `main.gd` is saturated with player-only wiring that a "headless" branch would have to route around at every touch point, not bypass cleanly — 18 `@onready` gameplay HUD nodes (minimap, stance indicator, inventory grid, dialogue box, interaction list, gauntlet HUD…), a `SnapshotEventRouter` with a dozen player-centric `register_always`/`register` handlers (`update_zone`, `play_recognition_chimes`, `consume_dialogue`…), free-camera WASD panning tied to `GameState.free_camera_mode`, and a `_process()` loop whose entire second half is input-queue flushing (`InputMapper.flush_queue()``SimBridge.send_input()`). None of that exists to serve the Atlas — it exists to serve a playing character, which a reader connection never has (and, per §2, structurally cannot send inputs for even if it tried). A flag would mean auditing and branching every one of those systems to no-op correctly; a dedicated scene means writing on the order of 100 lines that do only what the Atlas needs, with zero risk of a reader session accidentally exercising player-only code paths (interaction prompts, dialogue, bug report capture) that assume a character exists.
**Boot sequence** (`atlas_standalone.gd`, modeled directly on `visual_capture.gd`'s live-mode wait blocks):
1. `_ready()`: run §1's auto-attach-else-spawn discovery (try `SR_PORT`-or-default-9876 connect, ~500ms timeout; on refusal, spawn a server child via the `server_process.gd` pattern with `--port 0` and parse `LISTENING:{port}`). Configure `SimBridge` accordingly (`server_path` set for spawn, unset + resolved attach port for attach).
2. Call `SimBridge.connect_to_sim()` using the **Reader handshake variant** (§2's `role: ConnectionRole = Reader` on `StartupMessage`), not the character-startup path `main.gd` uses. This is the one place `atlas_standalone.gd`'s connect call diverges from `main.gd`'s.
3. Poll `SimBridge.state` until `CONNECTED` — same `ConnectionState` enum, same polling shape as `visual_capture.gd`'s live-mode wait, minus the fixed-frame-count settle (a real window can just `await` the signal instead of budgeting frames for a screenshot).
4. On connect: `HudGroups.open_app("implant/map")`. There is no gameplay group ever registered in this scene, so D-170's gameplay/implant mutual-exclusivity degenerates harmlessly to "implant is always the sole active exclusive group" — no `HudGroups` code changes needed; the invariant it enforces (only one of gameplay/implant visible) is trivially satisfied when gameplay never registers anything.
5. `ImplantRegistry.instantiate_all(self)` — the same call `hud.gd._ready()` makes in the normal game — populates every installed implant app (Atlas + Economics both come along for free; Economics degrades gracefully since it's reachable but not the entry point, and read-only holds for it too automatically, since it rides the same Reader connection).
6. The Atlas's own `KEY_M`/`KEY_ESCAPE` handling (`atlas_app.gd`) currently calls `HudGroups.close_app()` on M/Escape from the top-level "reach" screen, which would leave the standalone window showing a blank Control with nothing to fall back to (there is no gameplay layer). Two options, left for the implementation ticket to pick: (a) `atlas_standalone.gd` intercepts the close and either quits the app or re-opens `implant/map` instead of demoting to a nonexistent gameplay layer, or (b) `atlas_app.gd` gains a `standalone_mode` flag that no-ops the close-to-gameplay branch. **(a)** is recommended — it does not touch `atlas_app.gd` at all, keeping the in-game and standalone Atlas byte-identical.
**Window title/branding:** `atlas_standalone.tscn` sets its own window title via `DisplayServer.window_set_title()` in `_ready()` (e.g. "The Settled Reach — Atlas"), since `project.godot`'s shared `config/name` would otherwise make the standalone window read identically to the main game window in the taskbar/alt-tab — a second-monitor companion needs to be visually distinguishable at a glance. This is the only project-level Godot config touched; no `run/main_scene` override, no export preset changes in this ticket.
**Dev launch (un-exported):** `make atlas` runs `$(GODOT) --path client client/scenes/atlas_standalone.tscn` — Godot accepts an explicit scene path as a positional argument, overriding `run/main_scene` for that invocation only (the same mechanism `godot --path client -s res://tests/visual_capture.gd` already uses to run a non-default entry script). No `project.godot` edit needed; `run/main_scene` stays `main_menu.tscn` for the normal game. Since discovery (§1) is auto-attach-else-spawn at runtime, `make atlas` itself stays a single simple target — it does not need `make game`'s explicit background-`cargo run` + `sleep` + launch + `make stop` choreography, because `atlas_standalone.gd` owns its own spawn decision and child-process lifecycle internally (§1/§2). `make atlas` is just: build client, launch it.
**Exportable later:** because this is a genuine second scene (not a runtime-detected mode), it is also a legitimate Godot **export preset** target down the line — `godot --export-release "Atlas" build/atlas/...` with `atlas_standalone.tscn` as that preset's main scene. Nothing in this design blocks that; it is out of scope for this ticket (no export preset is added now) but the architecture does not need to change to support it later. This directly serves purpose (2) in the epic: "remains available as a LEGITIMATE player-facing pattern post-release." One caveat inherited from §2/§6, flagged here because it bears on export/distribution specifically: the default bind (`127.0.0.1:9876`) is loopback-only, and loopback is the entire security boundary the read-only guarantee currently leans on. A same-machine export is safe as designed. A LAN companion (a genuinely different second monitor — a different physical machine on the same network) is a different, larger feature: it requires the non-default-bind + real-auth work §6 already flags as a prerequisite for `TradingReader`, and arguably for `Reader` too once "same machine" stops holding. Not built now; recorded so nobody exports this to a non-loopback bind by default.
**(4) DATA BROWSER — "scan ALL database data."**
**Browse surface.** A new implant app, `implant/browser` (or folded into the Atlas as a new top-level screen reachable from "reach" — the implementation ticket picks the exact navigation entry point; recorded here as its own app since the entity set is broader than geography and doesn't naturally nest under the Atlas's reach→system→planet→regional drill-down), composed entirely from the existing D-169 component library (`ImplantPanel`/`ImplantHeader`/`ImplantDataRow`/`ImplantTextBlock`/`ImplantSeparator`) — no new UI primitives needed, this is exactly the list+detail pattern the library was built for. Two screen shapes, reused per entity kind:
- **Index screen** — a scrollable `ImplantDataRow` list (name + one or two summary columns), filterable/searchable by name, one per entity kind.
- **Detail screen** — an `ImplantPanel` of `ImplantDataRow`s (and `ImplantTextBlock` for free text / descriptions) showing every column the wire response carries for that one entity, `nav.push()`-reachable from the index row.
**v1 entity scope (deliberately narrow, honest about phase).** `systems-schema.sql`'s table set spans registry data (systems, bodies, stations, corporations, commodities, trait templates) and cascade-derived atlas geometry (`atlas_cities`, `atlas_roads`, `atlas_rivers`, `atlas_province_boundaries`…) that is Phase-4-in-progress and per-body-optional (populated only once a body's generation cascade has run — the same `AtlasLayerStatus::Ready`-vs-`Pending` gating the Atlas's regional screen already handles). v1 ships **registry-tier screens only** — tables that exist, are fully populated, and are stable regardless of cascade progress:
1. **Star systems** (`star_systems` + `system_economy`/`system_factions`/`system_culture` folded into one detail screen — small tables, natural 1:1 join)
2. **Bodies** (`bodies`, filterable by system — the existing `SystemScreen`'s body list is the UI precedent)
3. **Stations** (`stations`)
4. **Corporations** (`corporations` + `corp_presence`/`corp_financial_state` folded in)
5. **Commodities** (`commodities` + `production_chains`/`chain_inputs`)
6. **Trait catalog** (`trait_templates`) — Jeroen's brief names this explicitly
Deliberately **excluded from v1**, left for a follow-up ticket once Phase 4 cascade tables stabilize: `atlas_cities`/`atlas_roads`/`atlas_railroads`/`atlas_pois`/`atlas_rivers`/`atlas_oceans`/`atlas_mountain_ranges`/`atlas_province_boundaries` (cascade-derived, per-body, partially populated mid-Phase-4 — a browser screen over a table that's empty for most bodies today is not a useful v1 screen) and `corp_lifecycle_events`/`system_history`/`historical_events` (event-log tables, better served by a future timeline/log UI shape than list+detail). The six-entity v1 list above is the full set of "always fully populated, one row = one interesting thing" registry tables; everything else waits.
**Data path — wire-only, extending the existing proxy pattern (no local SQLite read).** Two options exist in principle: (a) the client opens `server/data/systems.db` directly (it already ships in the client build — instant, complete, works even with no server running), or (b) every browser screen is a wire request/response pair through the bridge, exactly like `StarMapRequest`/`CityNamesRequest` today. **This record picks (b), unambiguously, for two independent reasons:**
- **Pragmatic: Godot has no built-in SQLite.** `client/addons/` holds exactly two addons today (`gdUnit4`, `messagepack`) — no SQLite driver exists anywhere in the client. Reading `systems.db` locally would mean adding a third-party GDExtension (e.g. `godot-sqlite`) as a new dependency. D-020 explicitly rejected GDExtension for the core client-server bridge specifically to avoid "gdext pre-1.0 API churn, Godot version ABI breakage, FFI thread safety" — introducing a GDExtension now, for a companion-app convenience, reopens exactly the risk category D-020 spent effort closing. This is not a hard architectural violation (D-020 scoped its GDExtension rejection to the simulation bridge, not "any GDExtension ever") but it is the wrong trade for a feature whose entire value proposition is "lightweight."
- **Architectural: the codebase already made this call, recently, on purpose.** T-949 migrated the star map — 100% static, authored, non-per-body data — off a direct client-side `FileAccess` read of `star_map_data.json` and onto a wire request, specifically because "the client never reads game data files directly" (D-010 boundary framing). That decision already resolved the "but this data is static, why not read it locally" question a companion-app data browser would otherwise re-litigate — T-949 answered it for star-map data, and there is no principled reason `star_systems`/`bodies`/`corporations` are different in kind. One data-access rule for the whole client (server-authoritative reads, always through the bridge) is simpler to reason about and extend than "static tables read locally, dynamic tables read over the wire, judgment call per table" — especially since today's "fully static" table can grow a cascade-dynamic column later (`corp_financial_state` already looks time-varying).
So: **static registry data is NOT read locally — it goes through the SAME wire path as everything else**, because the server is already the sole owner of `systems.db` access and that ownership is a feature (single source of truth, single enforcement point for D-010 boundaries), not a latency cost worth working around. The "instant, complete, offline-capable" properties Jeroen's brief names as motivations are achieved a different way: attach-mode's "instant" comes from a fast local TCP round-trip (sub-millisecond on loopback — the ~1-5ms serialization cost D-020 already accepted is not the bottleneck for a data browser that isn't rendering 60fps), and "complete" comes from reading the same open handle the running server already has, with no second file-format copy to keep in sync.
**Server-side extension (the actual new work).** One new proxy, following `atlas_data_proxy.rs`'s established shape: **per-entity-kind request types** (mirroring `StarMapRequest`'s "thin, one dataset" shape), not a generic SQL-ish query surface — a generic query API is a much bigger security/complexity surface for a v1 feature that only needs six fixed table shapes, and is explicitly rejected for that reason. Each handler is a `rusqlite` read against `systems.db` using the exact `CityContextReader::open()`-style pattern already proven server-side — the server already has this dependency and this pattern; this ticket is "write five more read functions," not "introduce a new capability."
**D-010 boundary note — the "no character" framing, reinforced by §2.** A reader connection has no character (§2: it receives no `ObserverSnapshot` at all), so there is no per-character knowledge/fog to bound against — this is a SIMPLER boundary case than the normal player observation, not a harder one. What the Reader class is allowed to see is bounded by **connection class**, not character knowledge state, and §2 already proved the six v1 entities pass that bar independently (their handlers take no observer/character parameter — they were install-static/world-public before this record, not a carve-out invented for readers). The one thing explicitly ruled OUT of v1 scope: browsing a **specific save's diverged dynamic state** (an economy snapshot that has drifted from the shared baseline via play, one corp's post-game-start financial trajectory) is information a Reader attached to someone else's playthrough should not casually have. v1's six entities are registry-tier (identical across all saves, cascade-independent), so this doesn't bite yet — it becomes live the moment a market-state screen is added (§6) or a cascade-tier table (the excluded list above) is browsed against an attach-mode connection to someone else's running game. Flagged here so whichever follow-up ticket adds those screens re-reads this paragraph first.
**(5) SAVE/LOAD SEAM — recorded hook, not built.**
Saves are Phase 5+ (per the cascade); `meta.schema_version` (T-888) already carries the lineage-migration seam on the DB side, but no save file format or save/load UI exists yet anywhere in the client. This record fixes WHERE the Atlas's save/load interaction slots in, once it exists, without building any of it:
- **Attach-mode** has no save/load UI at all — it inherits whatever world the attached game session is running, save/load included; the Atlas is a read-only window onto a live session, and "loading a different save" from inside an attached reader is a contradiction (that's just attaching elsewhere, not loading). No hook needed here.
- **Spawn-mode v1** (this ticket's actual scope) offers **seed selection only** at launch — the standalone app's own minimal startup screen (part of `atlas_standalone.tscn`, shown before the `HudGroups.open_app("implant/map")` call in the boot sequence above) asks for a world seed the same way `character_creation.tscn`/`GameState.world_seed` does today for a normal new game, then spawns a Reader-role server against that seed via §1's `StartupMessage.world_seed` (not a CLI flag — §1 already fixed this as the single source of truth for spawn-mode seeding).
- **Spawn-mode's future save picker** slots into that SAME pre-Atlas startup screen, as a second choice alongside "new seed": once a save file format exists, the startup screen gains a "load existing save" option that spawns the server and immediately issues whatever the (then-existing) `LoadGame` flow is — the exact wire action `main.gd`'s `_dispatch_pending_load()` already sends today (`InputMapper.Action.LOAD_GAME``SimBridge.send_input()`), reused verbatim. The Reader-role server applies the load exactly as a normal server does, then simply never accepts player inputs afterward (§2's enforcement doesn't care how the world was populated — it gates on connection role, not on world provenance). **No new save/load mechanism is invented for the Atlas** — it is a consumer of whatever Phase 5+ builds, hooked in at exactly one point (the pre-launch startup screen), recorded now so future work knows the seam exists and where.
**(6) FUTURE TRADING — what changes when the app gains write verbs.**
Designing the seam now, not implementing it.
**Per-verb allowlist via a widened role, not a new connection type.** The `ConnectionRole` enum from §2 extends to `Player | Reader | TradingReader`. `TradingReader` is strictly **additive** to `Reader` — everything a `Reader` gets, plus a narrow, explicitly-enumerated `PlayerAction` allowlist for trade verbs — never a replacement. This keeps `Player ⊇ TradingReader ⊇ Reader` a strict superset relationship, so widening later only adds match arms at the same enforcement point (§2's role-gated input handling) and never touches the `Reader` path at all — the base read-only guarantee this whole record establishes is structurally unaffected by trading being added later.
**Idempotency/ordering.** Trade commands travel through the existing `tick`-stamped `PlayerInput{tick, action}` envelope (not a bespoke unstamped request), so ordering against the Player's own concurrent actions falls out of the existing `InputQueue` ordering for free — no new sequencing mechanism needed. Unlike movement (visibly-wrong-but-harmless if accidentally duplicated), a duplicated trade command is a real bug class (a double-sell). Recommend a client-generated idempotency token + a short server-side dedup window — cheap and bounded for a localhost, single-user, low-frequency command class. Rejected alternative: relying on TCP's delivery guarantee alone — that only catches transport-level duplication, not the actual threat (a user double-clicking through a UI hiccup and generating two distinct, both-valid application-level messages).
**Identity/auth — the assumption that must stay visible.** Same machine, same user, no auth — loopback-only IS the security boundary (the server already effectively enforces this via the `127.0.0.1:9876` default bind). This reasoning breaks the instant `SR_ADDR` or any non-default bind lets a `TradingReader` connect from a different machine — which is exactly D-009's actual multiplayer future, or even this record's own §3 export-later note about a genuinely-remote second-monitor companion. **The moment loopback-only stops holding, real auth (at minimum a session-minted token) is required before `TradingReader` widens beyond it.** This assumption is recorded here explicitly so it is visible to whoever eventually picks up a LAN-companion or remote-trading idea, rather than being silently inherited as "it already works, why would auth be needed."
- **Rationale:** Three independent product goals (Jeroen's brief) converge on one architecture cleanly: a dev data-inspection surface (purpose 1), a legitimate post-release second-monitor pattern (purpose 2), and an attach-or-spawn reader with a save seam (purpose 3) all want the SAME thing underneath — an implant UI that can run without a player. Building that once (dedicated entry scene + `ConnectionRole`-gated reader connection + wire-only data access) serves all three simultaneously; there is no version of this where the dev tool and the shipped companion app are different pieces of software. The wire-only data path is the one design choice that could have gone either way and didn't — it is deliberately consistent with T-949's precedent rather than reopening it, and it avoids a new GDExtension dependency for a "lightweight" feature. The app-shell choice (dedicated scene over a `main.tscn` flag) keeps blast radius smallest: the standalone Atlas cannot regress player-only code paths because it never touches them. The `Reader`/`TradingReader` superset relationship (§2/§6) means the read-only guarantee this record exists to make is never at risk from the later trading feature — it can only be extended, never weakened, by construction.
- **Implementation:** New ticket tree under [T-1128](../../.pql) (epic) — proposed tree delivered in the T-1129 design-pass report, not filed here (tree ownership: team lead). Client: `client/scenes/atlas_standalone.tscn` + `atlas_standalone.gd`, a new `implant/browser` app (or Atlas-nested screen) under `client/ui/implant/apps/`, `Makefile` `atlas` target. Server: `ConnectionRole` on `StartupMessage`, the accept-loop + `BridgeResource` multi-connection change, per-connection response tagging, and the Player-only `ServerRunning`/snapshot-targeting fixes (§2) in `server/src/bridge/`; a new `BrowseRequest`/`BrowseResponse` proxy in `server/src/atlas/` (sibling to `atlas_data_proxy.rs`, §4). No `systems-schema.sql` changes required — v1's six entity screens read existing tables as-is.
- **Cross-reference:** [D-010](#d-010) (client-server boundary — the wire-only data-path rationale; "no character" reader framing; the adversarial-client enforcement stance), [D-009](#d-009) (multiplayer design-for-it baseline — this record's 0-1 Player + 0-N Reader model is explicitly NOT that larger ambition), [D-020](#d-020) (subprocess/IPC over GDExtension — why local SQLite is rejected; `SimBridge`/bridge trait extension point), [D-169](#d-169) (implant component library — the data browser is composed entirely from existing components), [D-170](#d-170) (HudGroups — the standalone scene's degenerate single-group case), [D-192](#d-192) (no lockstep negotiation precedent — why `ConnectionRole` is a `StartupMessage` field, not a new pre-handshake message). T-949 (star map wire-migration precedent this record extends rather than re-litigates), T-888 (schema_version save lineage — the seam §5 hooks into once it exists). Tickets: [T-1128](../../.pql) (epic), [T-1129](../../.pql) (this design pass).
- **Raised by:** Jeroen (2026-07-17, brief: standalone Atlas via `make atlas`, read-only reader against the running game or its own spawned server, save/load interaction seam, future trading). Designed by Tyre (architecture lead, §3–§5, integration, record author) + Oscar (§1, §2, §6 — connection model, reader protocol, trading seam).
- **Dissent:** None recorded at design time. Two judgment calls flagged for confirmation rather than dissent, both revisable by the implementation ticket without touching the rest of this record: (a) §4's choice to make the data browser a **separate `implant/browser` app** rather than a new top-level screen nested inside the existing Atlas — the six v1 entity kinds don't share the Atlas's geographic drill-down shape, so a separate app was chosen for navigational clarity (the Atlas stays "the map," the browser is "the database") but this is a naming/IA call, not architecture; (b) §3's `atlas_app.gd`-unmodified close-handling option (a) vs. a `standalone_mode` flag option (b) — recommended but not forced.
---
*108 decisions (D-001 through D-254, excluding gaps). Last updated: 2026-07-17 (D-254 — standalone Atlas companion app: dedicated entry scene, ConnectionRole-gated reader connection (0-1 Player + 0-N Reader), wire-only data browser extending the T-949 proxy pattern, save/load and trading seams recorded not built).*