# Scoped task: facade → announced-set membership + engine boundary tightening

**Status:** proposed (not started)
**Owner decision pending:** the idempotent-discoverParticipants ergonomics (see Open questions)

## Goal / invariant

A developer building a game on `EasyMultiplayer` can ground game logic **only on admitted
participants**. Pre-admission state — the heard-from set, candidates asking to join, spectators —
is **unreachable through the documented API**. The dev makes exactly two membership decisions
(what input counts as joining; when to admit), and everything else is automatic.

This is a DETERMINISM invariant, not just tidiness: pre-admission membership ("who have I heard
from") is a per-node, timing-dependent set, so any game logic that branches on it diverges across
peers. Only `participants()` is identical on every node (the engine documents this at
`SimulationEngine.participants()`).

## Why this is also a correctness fix, not just a facade wiring

Audit (2026-06-11) found the dev boundary currently LEAKS the unsafe set:

- `reconstructedIds()` is PUBLIC (`SimulationEngine.js` ~3418) and returns every decoder key =
  participants + candidates + spectators. The facade hands it straight to the dev as `ctx.players`
  (`EasyMultiplayer.js` ~214) and fires `playerJoined` off it (~189). So the shipping facade is
  built on the non-deterministic set.
- `query(playerId, …)` is NOT participant-gated (~3399): it queries any decoder, so a spectator can
  be queried. The "discover, THEN get a handle" invariant is not enforced. (A gated reader, `poll`,
  exists from Cat 24 but the facade routes around it via `query`/`reconstructedValueAt`.)
- An `isDiscoverable`-style public accessor (~3573) exposes pre-admission candidate state.

## Scope

### Part A — Engine boundary tightening (safety)
- **A1. Gate the dev read path to participants.** The facade-facing read (`getInput`/`query`) must
  reject a non-participant (dev-throw / null), routed through the `poll` participant-gated
  semantics. Querying a non-discovered id becomes impossible, not silently allowed.
- **A2. Demote `reconstructedIds()` from the dev-facing surface.** Keep it for the engine's own
  recovery/roster use (lines ~4258/4588/4617) and white-box tests, but it must NOT be a consumer
  "players" source. Mark it unsafe-internal; remove every facade/consumer use.
- **A3. Audit discoverable/candidate accessors** (`isDiscoverable` et al.) — no PUBLIC method may
  enumerate pre-admission candidates.
- **A4. `#private` pass (targeted).** Convert genuinely-internal helper methods that have NO test or
  external reader to true `#private`. DO NOT `#`-ify test-asserted internals: 22 test files reach
  `_`-internals (77 distinct props; `_epoch` 55×, `_frameAuthority` 48×, `_tracker`, `_decoders`,
  `_knownLive` …). Those stay on the `_` convention (white-box tests are trusted; the threat model
  is a shipped game going through the facade, not the test suite).

### Part B — Facade membership model (`EasyMultiplayer.js`)
- **B1. Expose the admit decision in the deterministic tick ctx** (runs inside `_step`, rollback-safe):
  `ctx.discoverParticipants(pred, limit)`, `ctx.participants`, `ctx.isParticipant(id)`. NEVER expose
  candidates/spectators.
- **B2. Join is a dev-marked input.** The dev sets `discoverable: true` on whatever input they decide
  is the join action (already flows through `_sampleLocal` verbatim; add a documented convention /
  optional friendly alias). Leave = a `stopParticipating` input.
- **B3. Move all membership reads to the announced set:** `playerCount`, `playerJoined`, `ctx.players`
  → `participants()`; `playerLeft` → `getStoppedParticipating()` (also retires the current
  not-rollback-safe transport-liveness leave hack).
- **B4. `ctx.getInput` / `ctx.query` use the gated reader from A1.**
- **B5. Enable `participationRetention: true`** (the payoff: bounded memory + state-hashing) — only
  valid once B1-B4 land.

### Part C — Tests
- **C1.** Reconcile facade tests (graph-pacman, easy-multiplayer-integration, synced-tick-facade) to
  admit via `discoverParticipants` + a discoverable join input. Expect input-shape / change-count /
  hash churn (same flavor as the §6.1 participant-gate reconciliation, no new mechanisms).
- **C2. New falsifying/boundary tests:** (a) a dev CANNOT see or query a non-participant through the
  facade; (b) a never-admitted spectator's input never influences game state; (c) admit →
  participant → `getInput` works end to end and converges across peers.
- **C3.** If `reconstructedIds` is demoted/renamed, migrate or re-document its 9 test callers.

## Risks / constraints
- True `#private` is bounded by the white-box suite (22 files) — only the test-free internals qualify.
- `participationRetention` changes checkpoint hash semantics (state vs input-log) → facade test churn.
- The engine's `discoverParticipants` is idempotent / recomputed-each-call; the facade should call it
  every tick with the dev's predicate (converges + rolls back cleanly) rather than a one-shot.

## Open questions
1. **Ergonomics:** document "call `ctx.discoverParticipants(pred, limit)` every tick", or add a
   fire-once `onJoinRequest` sugar on top? Every-tick is the determinism-safe primitive; sugar risks
   reintroducing ordering questions.
2. Keep `reconstructedIds()` public-but-unsafe-documented, or hard-rename to `_heardFromIds()` and
   migrate the 9 test callers?

## Non-goals
- Converting all engine internals to `#private` (breaks the white-box suite).
- A full lobby / matchmaking / spectator-UI API (this is just the membership primitive).
