···185185| System (in-process main) | any publish | always allow; never subscribes |
186186| Tile with valid token | publish to topic T | grant includes `publish` for T's class (subject to per-class rules below) |
187187| Tile with valid token | subscribe to topic T | grant includes `subscribe` for T's class |
188188-| Tile with revoked/missing token | any op | reject — logged as `tile:drift` telemetry, never silently dropped |
188188+| Tile with revoked/missing token | any op | reject — logged as `gate:rejected` telemetry, never silently dropped |
189189190190Per-class rules (the capability-grant shape implements these):
191191···2172172182181. **Channel allowlisted?** `registerTileIpc(channel, handler, descriptor)`
219219 is the only way to attach a `tile:*` listener. An unregistered
220220- channel receives a default handler that logs `tile:drift` and drops.
220220+ channel receives a default handler that logs `gate:rejected` and drops.
2212212. **Sender identity verified?** `event.sender` (the `WebContents` that
222222 sent the frame) must match the `WebContents` that owns the
223223 `payload.token`. This closes the "forged token" hole — a tile with
224224 XSS cannot smuggle out another tile's leaked token because the
225225 sender frame wouldn't match.
2262263. **Payload schema valid?** Each channel descriptor declares its
227227- expected shape. Malformed frames log `tile:drift` and drop.
227227+ expected shape. Malformed frames log `gate:rejected` and drop.
2282284. **Token valid & grant consistent with channel?** The channel
229229 descriptor names the capabilities required; absent ones → reject.
2302305. **State-at-receive matches channel's expected transition window?**
231231 E.g., `tile:lifecycle:ready` may only arrive while the tile is in
232232- `loading`; arrival in any other state → reject + `tile:drift`.
232232+ `loading`; arrival in any other state → reject + `gate:rejected`.
2332336. **Sender role allowlisted for this channel?** E.g.,
234234 `tile:lifecycle:ready` must come from a tile's own preload, never
235235 from Core or System.
236236237237Only after all six pass does the handler run. Rejections are never
238238-silent; every drop emits `tile:drift` with a structured reason so CI
238238+silent; every drop emits `gate:rejected` with a structured reason so CI
239239can fail on regressions.
240240241241**Performance budget**: schema validation (step 3) uses hand-rolled
···351351- **Tile publishes are gated by token existence.** A tile in `loading`
352352 has no token yet (the token is minted on `loading → ready`). Any
353353 publish attempt from a `loading` tile fails the chokepoint's token
354354- check and is rejected + logged as `tile:drift`. No capability check
354354+ check and is rejected + logged as `gate:rejected`. No capability check
355355 is needed — there's no token to consult.
356356- **No replay needed.** Because subscribers exist before any publisher
357357 fires, `cmd:request-registers` and the `registeredPayloads` cache in
···381381## Bypass detection
382382383383Everything routed through the FSM is enforced at the gate; rejections
384384-emit `tile:drift` telemetry (see §Authorization rules). The only way
384384+emit `gate:rejected` telemetry (see §Authorization rules). The only way
385385for a message to escape the FSM is for code to call a lower-level
386386Electron API directly, sidestepping the gate entirely. The FSM cannot
387387prevent this — anyone writing main-process code can do anything —
···395395 `webContents.send` that throws on the same pattern.
396396- **Off-path window creation** — `new BrowserWindow()` with a
397397 `peek://{tileId}/...` URL outside the tile launcher. Mitigation: lint
398398- rule + dev-mode assertion in the FSM that every BrowserWindow with a
399399- tile URL has a corresponding `registered → loading` transition.
400400- (Inherited from tile-lifecycle-fsm.md §Drift detectors.)
398398+ rule + dev-mode assertion that every BrowserWindow with a tile URL has
399399+ a corresponding `registered → loading` transition. (Inherited from
400400+ tile-lifecycle-fsm.md.)
401401402402Both are dev/CI-only assertions. Production has nothing to check —
403403-correct code can't trip them, and incorrect code is caught at review
404404-or in dev.
403403+correct code can't trip them, and incorrect code is caught at review or
404404+in dev.
405405406406-What is explicitly **not** a drift detector:
407407-- Gate rejections (enforcement, already covered).
406406+What is explicitly **not** tracked as rejection telemetry:
408407- Startup races (if they occur, the FSM design is wrong — fix the design,
409408 don't paper over it with a detector).
410409- Unrouted publishes (legitimate for domain events with no listeners).
411410412412-**Drift emission rate limit**: `tile:drift` publishes are themselves
411411+**Rejection emission rate limit**: `gate:rejected` publishes are
413412rate-limited to one event per `(tileId, reason)` tuple per second, with
414413a dropped-count aggregator. A buggy or malicious tile spamming rejected
415415-frames cannot amplify into a drift-publish storm that saturates the
414414+frames cannot amplify into a telemetry storm that saturates the
416415broadcaster.
417416418417## Module layout
···432431 wiring, pre-publish hooks. Imports `pubsub-fsm.ts` only. Exposes
433432 `unsubscribeAllByPrefix(tileId)` for lifecycle cleanup.
434433- **`backend/electron/tile-ipc-gate.ts`** (new) — the single IPC
435435- chokepoint (see §The IPC chokepoint). Exposes `registerTileIpc(channel,
436436- handler, descriptor)`. Runs channel allowlist check, sender-frame
437437- cross-check against token owner, payload schema validation, token
438438- validation, state-at-receive assertion, sender-role allowlist.
439439- Every drop emits `tile:drift`. No `tile:*` IPC handler is attached
440440- except through this gate.
434434+ chokepoint AND the rejection-telemetry surface AND the bypass
435435+ detectors. One module owns the whole gate boundary:
436436+ - `registerTileIpc(channel, handler, descriptor)` — the only way to
437437+ attach a `tile:*` IPC handler. Runs the six-step validation
438438+ sequence (see §The IPC chokepoint).
439439+ - `emitGateRejected(reason, ctx)` — rate-limited publish of
440440+ `gate:rejected` (one event per `(tileId, reason)` per second,
441441+ dropped-count aggregator). Used by the gate itself.
442442+ - `installDirectSendGuard()` + `installOffPathWindowGuard()` —
443443+ dev-mode monkey-patches catching the two bypass categories
444444+ (§Bypass detection). Called at app init.
445445+ - Subsumes what Phase 2 scaffolded as `tile-ipc-sender-check.ts` —
446446+ the sender-frame check becomes step 2 of the pipeline, not a
447447+ separate helper module.
441448- **`backend/electron/tile-ipc.ts`** — individual channel handlers,
442449 registered via `registerTileIpc`. On `tile:pubsub:publish` /
443450 `tile:pubsub:subscribe`: the gate has already validated the frame, so
444451 the handler just calls `pubsubFsm.allow(...)` for per-topic
445445- authorization → publish or reject+drift. This is where the
446446- lifecycle↔pubsub boundary sits.
452452+ authorization → publish or reject. This is where the lifecycle↔pubsub
453453+ boundary sits.
447454- **`backend/electron/tile-lifecycle.ts`** — state store + token
448455 lifecycle. On `loading → ready`: mint token. On `→ unloading` /
449456 `→ crashed`: revoke token, call `pubsub.unsubscribeAllByPrefix()`.
450457 Publishes `tile:state-changed` for observers. Imports from
451458 `pubsub.ts` (one-way); never imported by it.
452452-- **`backend/electron/tile-drift.ts`** (new) — bypass detectors (the
453453- two from §Bypass detection) + dev-mode wrap of `webContents.send`.
454454- Owns the `tile:drift` GLOBAL topic used by the gate's rejection
455455- telemetry.
456459- **`backend/electron/tile-lazy.ts`** — load-on-dispatch pre-publish
457460 hook. Already lives on the lifecycle side (it triggers
458461 `registered → loading`). Continues to register as a pre-publish hook
···483486 prepares for it — this check is worth landing early because it's
484487 security, not cleanup.
485488 **Test**: new unit test that simulates a mismatched sender + valid
486486- token → rejected with `tile:drift`.
489489+ token → rejected with `gate:rejected`.
4874903. **Phase 3 — Collapse to one command-result path.** Delete the
488491 `tile:command:result` IPC (preload send site + main-process
489492 handler). All command results flow through capability-gated pubsub
···513516 load-on-dispatch. **Test**: dispatcher to a tile whose preload throws
514517 → user-visible notification within 10s.
5155188. **Phase 8 — IPC chokepoint + bypass detectors.** Introduce
516516- `tile-ipc-gate.ts` with `registerTileIpc(channel, handler,
517517- descriptor)`. Migrate every existing `tile:*` `ipcMain.on` through
518518- the gate. Gate runs the six-step validation sequence (§The IPC
519519- chokepoint). Add lint rules + dev-mode wraps for the two bypass
520520- categories (direct `webContents.send('pubsub:...')`, off-path
521521- `new BrowserWindow` for tile URLs). Wire gate rejections to the
522522- `tile:drift` topic with structured reasons so CI can fail on any
523523- drift event.
519519+ `tile-ipc-gate.ts` as the single module owning the gate boundary:
520520+ `registerTileIpc(channel, handler, descriptor)` with the six-step
521521+ validation pipeline (§The IPC chokepoint); rate-limited
522522+ `gate:rejected` rejection telemetry; dev-mode bypass detectors
523523+ (direct `webContents.send('pubsub:...')`, off-path `new BrowserWindow`
524524+ for tile URLs). Subsumes Phase 2's `tile-ipc-sender-check.ts` (folded
525525+ in as step 2 of the pipeline). Migrate every existing `tile:*`
526526+ `ipcMain.on` through `registerTileIpc`. Add ESLint rules for the two
527527+ bypass categories. Wire rejection events so CI fails on any
528528+ `gate:rejected` publish during test.
524529 **Test**: regression suite stays green; a deliberately-seeded
525525- bypass in a test fixture trips the detector; every `tile:*`
526526- channel has at least one passing + one rejecting gate test.
530530+ bypass in a test fixture trips the detector; every `tile:*` channel
531531+ has at least one passing + one rejecting gate test; rate limiter
532532+ test (100 rejections with same reason → one publish).
527533528534Each phase is independently shippable and each has a narrow failure mode.
529535···553559 matrix.
554560- `backend/electron/tile-ipc.ts` — individual `tile:*` channel handlers.
555561- `backend/electron/tile-ipc-gate.ts` (new, Phase 8) — single IPC
556556- chokepoint.
557557-- `backend/electron/tile-drift.ts` (new, Phase 8) — bypass detectors +
558558- `tile:drift` topic owner.
562562+ chokepoint + rejection telemetry + bypass detectors. Owns the
563563+ `gate:rejected` topic.
559564- `backend/electron/tile-preload.cts` — renderer-side publish/subscribe
560565 API + command registration.
561566- `backend/electron/main.ts:216` — broadcaster registration site