PubSub State Machine #
Status: design, pre-implementation. Drafted 2026-04-23 in response to three distinct manifestations of the same "lost publish" bug class during Phases E/F of the tile-lifecycle FSM rollout. Layers on top of tile-lifecycle-fsm.md — that doc governs when a tile is alive; this doc governs what messages can flow between tiles (and the core) in each state.
Goal: make the class of bug "a publish was dropped somewhere in the fabric" impossible. If a message is dropped, the state machine identifies exactly which transition was invalid. Runtime enforcement + bisectable tests derive from the machine.
Non-goals #
- Re-implementing the tile lifecycle FSM (that lives in tile-fsm.ts / tile-lifecycle.ts). This doc references it.
- Changing the renderer-visible API surface (
api.publish,api.subscribe,api.commands.register). Semantics tighten; shape stays. - Solving cross-machine / cross-process pubsub beyond the existing main-process hub. The fabric remains one hub.
Participants #
Every pubsub participant is in exactly one of these roles. Each role has a fixed identity (source address) and a fixed set of legal capabilities.
| Role | Identity | Notes |
|---|---|---|
| Core | peek://app/... |
bgWindow (apps/desktop/renderer/background.html → apps/desktop/renderer/index.js runs cmd registry / page / hud) + cmd panel UI window (apps/desktop/renderer/cmd/panel.html). Both carry trustedBuiltin; pubsub doesn't distinguish them. First-class publisher AND subscriber; every publish must reach bgWindow. |
| Tile | peek://{tileId}/{entry} |
One per tile BrowserWindow. Capability-gated via tile token. |
| System | peek://system/ |
Main-process code publishing on its own behalf (e.g., tag:item-added from datastore IPC handlers, window:focus-changed from app events). Not a window, not a renderer. In-process call to publish(); no token, no IPC hop. |
| Webview guest | peek://{feature}/... webContents inside a <webview> |
Hosted inside tile windows. Receives broadcasts via the same pubsub forwarding path. |
The token is the capability handle #
A tile's token is the capability handle for all pubsub and command
operations. The token is minted on loading → ready and revoked on
→ unloading / → crashed by tile-lifecycle.ts. This makes the token
the atomic, race-free signal of "tile is in ready or visible":
- If
validateToken(t)returns a grant, the tile is alive. - If it returns null, the tile is not alive — publish/subscribe rejected.
Consequence: the pubsub enforcement path does not read lifecycle
state. It validates the token and consults the grant. Token validity and
lifecycle state are kept in sync by tile-lifecycle.ts as the sole
owner of both. This removes a hot-path cross-module dependency between
pubsub.ts and tile-lifecycle.ts, eliminates the transition-race
question ("was the publish checked before or after the state change?"),
and keeps the pubsub FSM testable as a pure function (see §Module
layout).
Deleted concepts:
peek://{id}/lazy-stubpseudo-source — no longer a distinct participant. Lazy declaration is a state (registeredper the lifecycle FSM), not a fake source address. The load-on-dispatch hook reads lifecycle state directly (it lives on the lifecycle side of the boundary).- V1 extension-host iframes — gone as of 2026-04-21.
- Legacy
IPC_CHANNELS.SUBSCRIBE/PUBLISHpaths — folded into tile pubsub. Core uses the same capability-gated path as every tile, with atrustedBuiltingrant.
Transport layers #
All paths converge at publish() in pubsub.ts. There is exactly one hub.
Core/Tile renderer
│
▼ api.publish / api.subscribe (from tile-preload.cts)
│
ipcMain.on('tile:pubsub:publish') ──► capability-gated validate
ipcMain.on('tile:pubsub:subscribe') │
▼
publish() in pubsub.ts
│
┌──────────────────────────────┼──────────────────────────────┐
▼ ▼ ▼
in-proc callbacks pubsubBroadcaster (hook)
(topics Map) ├─ core window (bgWindow) pre-publish
— subscribers registered ├─ tile windows can skip/defer
via tile:pubsub:subscribe └─ webview guests
Binding rules:
- No component calls
webContents.send('pubsub:...')directly. The broadcaster is the only sender of pubsub IPC frames. - The broadcaster iterates bgWindow + all tile windows + qualifying
webview guests. Omitting bgWindow is a bug (historical; see
project_bgwindow_is_core.md). - Main-process code that wants to publish as System uses
publish(SYSTEM_ADDRESS, ..., topic, data)directly — no IPC hop.
Broadcaster fan-out — subscriber-indexed delivery
The broadcaster must not blanket-iterate every renderer on every
publish. It consults the per-topic subscriber set maintained by
tile:pubsub:subscribe: a window that never subscribed to topic T
never receives frames for T. This turns fan-out from O(N windows) to
O(S subscribers-of-T), typically much smaller.
This is the single biggest perf lever in the fabric — at high widget
counts (50+ tile windows, multiple webview guests each), blanket
iteration at 60Hz (e.g., for page:scroll-style topics) will saturate
IPC. Subscriber-indexed delivery keeps it bounded by actual interest.
For topics with genuinely many subscribers (e.g., tag:item-added
during bulk sync), the fan-out is still proportional to how many
renderers actually care — which is the minimum unavoidable cost.
Topic taxonomy #
Topics live in flat namespaces separated by :. Every topic belongs to one
of these classes. The class determines who may publish, who may subscribe,
and what capability gating applies.
| Class | Pattern | Publishers | Subscribers | Gate |
|---|---|---|---|---|
| Command dispatch | cmd:execute:{name} |
Core (panel + chains), other tiles (chained execution) | Owning tile's handler (via api.commands.register) |
Capability: tile must have commands. Load-on-dispatch hook may defer. |
| Command result | cmd:execute:{name}:result:{uuid} |
Owning tile | Dispatcher that set expectResult:true |
Capability: same commands grant (infra carve-out). |
| Command registry | cmd:register, cmd:register-batch, cmd:unregister |
Tiles with valid token (= state ≥ ready) |
Core (cmd registry) | Dynamic registrations only. cmd:register-batch is the bulk variant (used by noun expansion). Declared commands come from manifest cache (not pubsub) per tile-lifecycle-fsm.md §Invariants. |
| Noun registry | noun:register-batch, noun:unregister |
Tiles with valid token | Core (noun registry) | Same as cmd:register. |
| Noun dispatch | noun:browse:{name}, noun:query:{name}, noun:open:{name} |
Core (panel) | Noun handler tile | Capability: commands (nouns auto-generate commands). |
| Lifecycle observer | tile:state-changed (mirror of lifecycle transitions) |
tile-lifecycle.ts (System) |
Observers (drift detector, HUD widgets) with lifecycle-observer capability |
Read-only observer topic. Publishers must not publish this directly. lifecycle-observer is a new capability declared in the tile manifest (typical grantees: drift, HUD widgets that display tile state). |
| Domain events | tag:*, item:*, sync:*, editor:*, entities:*, page:*, window:*, etc. |
System (datastore IPC handlers) + tiles whose manifest declares ownership of the namespace (e.g., tags feature owns tag:*) |
Any tile whose capability grant allows subscription to the namespace | Capability allowlist on BOTH publish and subscribe sides. Namespace ownership is a manifest field: owns: ['tag']. |
| Feature-scoped | {feature}:{verb} (e.g., websearch:engine-request) |
That feature's tile(s) | That feature's tile(s) | Capability allowlist. Cross-feature publish is a violation — see §Cross-feature rule below. |
| Settings | topic:core:prefs, settings:changed:{feature}, settings:navigate |
Core (settings UI) | Any tile whose capability grant allows subscription to the topic | Publishers are Core/System; subscribers pass the normal capability allowlist through the gate. |
Cross-feature rule #
A feature tile may not publish to another feature's topic namespace. If
A needs data from B, it calls B's registered command (api.commands.call
→ cmd:execute:{b-command} round-trip), which goes through the full
dispatch state machine. This rule exists because ad-hoc cross-feature
topics (see historical/tasks.md: websearch bg↔home round-trip) are the most common
source of fragile pubsub.
Topics that are NOT pubsub: private lifecycle IPC #
tile:ready and tile:shutdown are not pubsub topics. They are
private IPC signals between a tile's preload and tile-lifecycle.ts:
tile:lifecycle:ready— preload → main, sent once after capability token validated. Causes lifecycle transitionloading → ready.tile:lifecycle:shutdown— main → preload, sent during unload grace window.
Why private: tile:ready is the signal that admits a tile as a
publisher. If it flowed through pubsub itself, the tile would be
publishing before it has been admitted — a bootstrap circularity that
either requires a carve-out or introduces a race. Private IPC removes
the circularity entirely.
Observers that need to react to lifecycle transitions (drift detector,
HUD widgets) subscribe to the mirrored tile:state-changed pubsub
topic, which tile-lifecycle.ts publishes as System after a transition
lands. One direction, after the fact.
Deleted topics #
cmd:request-registers— race workaround where Core on boot asks all tiles to replay their cachedcmd:registerpayloads. Exists only because subscribers could land after publishers. With the FSM's invariant that Core's subscribers are live before any tile reachesready, this topic has no job. Delete.ext:ready,ext:all-loaded— v1 lifecycle handshake. Replaced entirely by the privatetile:lifecycle:readysignal + cache-backed declared commands. Delete.
Authorization rules (not state lookups) #
The pubsub enforcement layer does not inspect lifecycle state. It runs
one check: validateToken(t) → grant | null, then consults the grant
to answer "is this (publisher, topic, op) allowed?"
Because tile-lifecycle.ts mints the token on loading → ready and
revokes it on → unloading / → crashed, this single check implicitly
enforces the full state-based policy. Rules:
| Publisher | Operation | Check |
|---|---|---|
Core (trustedBuiltin grant) |
any publish, any subscribe | always allow |
| System (in-process main) | any publish | always allow; never subscribes |
| Tile with valid token | publish to topic T | grant includes publish for T's class (subject to per-class rules below) |
| Tile with valid token | subscribe to topic T | grant includes subscribe for T's class |
| Tile with revoked/missing token | any op | reject — logged as gate:rejected telemetry, never silently dropped |
Per-class rules (the capability-grant shape implements these):
| Topic class | Who may publish | Who may subscribe |
|---|---|---|
cmd:execute:{name} |
Any with commands grant (dispatcher) |
Only the declared owner of {name} |
cmd:execute:{name}:result:{uuid} |
Only the owner of {name} |
Only the dispatcher that set resultTopic (private-by-uuid) |
cmd:register / cmd:unregister |
Any with commands grant |
Core only |
noun:register-batch / noun:unregister |
Any with commands grant |
Core only |
noun:browse:{n} / noun:query:{n} / noun:open:{n} |
Core (panel) | Owner of noun {n} |
tile:state-changed |
System only (tile-lifecycle.ts) |
Any with lifecycle-observer grant |
Domain event {ns}:{verb} |
System OR tile with {ns} ownership |
Any tile whose capability allowlist matches |
Feature-scoped {feature}:{verb} |
Only the {feature} tile(s) |
Only the {feature} tile(s) (cross-feature = violation) |
Lifecycle state is not consulted at the enforcement point. If the
token is valid, the tile is in ready or visible by definition. The
two FSMs couple through the token's lifetime, not through shared state
reads — this is the pure-function boundary that makes the pubsub FSM
testable in isolation.
tile:lifecycle:ready and tile:lifecycle:shutdown are private IPC
(§Topics that are NOT pubsub), so they never hit this table.
The IPC chokepoint #
All tile:* IPC frames pass through a single main-process gate
(tile-ipc-gate.ts, see §Module layout). The gate runs a fixed
sequence before any handler executes:
- Channel allowlisted?
registerTileIpc(channel, handler, descriptor)is the only way to attach atile:*listener. An unregistered channel receives a default handler that logsgate:rejectedand drops. - Sender identity verified?
event.sender(theWebContentsthat sent the frame) must match theWebContentsthat owns thepayload.token. This closes the "forged token" hole — a tile with XSS cannot smuggle out another tile's leaked token because the sender frame wouldn't match. - Payload schema valid? Each channel descriptor declares its
expected shape. Malformed frames log
gate:rejectedand drop. - Token valid & grant consistent with channel? The channel descriptor names the capabilities required; absent ones → reject.
- State-at-receive matches channel's expected transition window?
E.g.,
tile:lifecycle:readymay only arrive while the tile is inloading; arrival in any other state → reject +gate:rejected. - Sender role allowlisted for this channel? E.g.,
tile:lifecycle:readymust come from a tile's own preload, never from Core or System.
Only after all six pass does the handler run. Rejections are never
silent; every drop emits gate:rejected with a structured reason so CI
can fail on regressions.
Performance budget: schema validation (step 3) uses hand-rolled
shape checks (typeof x.foo === 'string' && Array.isArray(x.bar)),
not a general-purpose schema library (Zod / Ajv). Total gate cost
stays under ~5μs per frame — negligible next to Electron's intrinsic
IPC serialization + cross-process handoff (~10-50μs).
This chokepoint is the main-process analog of the authorization-rules table above — it guarantees that the rules can't be bypassed by a handler that forgets to validate, and it adds the sender-frame check that the pure FSM alone can't express.
Command dispatch & result — one path #
Rule: a command result returns to its dispatcher by publishing to the result topic through the same capability-gated pubsub path that every other publish uses. Specifically:
- Dispatcher publishes
cmd:execute:{name}with{expectResult: true, resultTopic: 'cmd:execute:{name}:result:{uuid}'}. - Handler in the owning tile publishes to that result topic when it completes.
- Both publishes go through
tile:pubsub:publish→ capability allowlist →publish().
The allowlist's cmd:execute:* infra carve-out for tiles holding the
commands grant (tile-ipc.ts, landed in commit f32063db on
2026-04-23) is what makes result-topic publishes legal without requiring
every tile to explicitly declare every possible UUID-suffixed topic in
its manifest.
Legacy side-channel removed (Phase 3, 2026-04-23): tile-preload
previously also sent a parallel ipcRenderer.send('tile:command:result', ...) frame that hit an unrestricted main-process publish handler
(ipcMain.on('tile:command:result', ...) in tile-ipc.ts). That
duplicate path predated the allowlist carve-out and bypassed the gate
entirely. Both sites are gone — one auth path, one code path, one
place to reason about who can emit a command result.
Dispatcher Owning tile
(Core/panel or another tile) (capability: commands)
│ │
│ publish('cmd:execute:X', │
│ { ..., expectResult:true, │
│ resultTopic:'cmd:execute:X: │
│ result:<uuid>' }) │
│ ───────────────────────────────────────►│
│ │
│ [load-on-dispatch hook
│ ensures state≥ready]
│ │
│ [handler runs]
│ │
│ publish('cmd:execute:X:
│ result:<uuid>',
│ { data } | { error })
│ ◄───────────────────────────────────────│
│ │
resolve proxy promise
Concrete removals (done Phase 3):
ipcRenderer.send('tile:command:result', ...)call inapi.commands.registerhandler — removed from tile-preload.cts. Thetile:pubsub:publishtomsg.resultTopicis now the sole path.ipcMain.on('tile:command:result', ...)handler in tile-ipc.ts — removed.
Scope semantics — delete #
The scope argument (SYSTEM=1 | SELF=2 | GLOBAL=3) is vestigial.
Survey results (2026-04-23):
api.scopes.GLOBAL— ~100 sites acrossapp/**.api.scopes.SYSTEM— 4 sites (3 inapps/desktop/renderer/index.js, 1 inapps/desktop/renderer/settings/settings.js).api.scopes.SELF— zero sites.
Preload's publishImpl defaults missing scope to SELF, but since no
caller passes SELF and SELF delivery is restricted to same-pseudo-host
subscribers (which for a one-window core = same-window), the SELF code
path is unreachable.
Plan (single commit, no backcompat grace period):
- Delete the
scopesconstant and thescopeCheck()function inpubsub.ts. Allpublish()calls route to both in-proc callbacks (filtered by topic match only) and the broadcaster. - Migrate the 4
api.scopes.SYSTEMsites to regular publishes on system-privileged topics (e.g.,topic:core:prefs). Privilege moves from the scope argument onto the topic — the topic's capability allowlist controls who may publish and who may subscribe. - Remove the
scopeparameter fromapi.publishandapi.subscribe. Migrate all ~100 call sites inapp/**in the same commit. No backcompat shim, no no-op tolerance.
Subscribe-before-publish invariant #
This is the missing rule that explains every "lost publish at boot" bug.
Statement: For any topic T and any publisher P, at the moment P publishes T, every subscriber S that will ever receive T during this boot must already be registered.
Enforcement:
- Core subscribers land first.
apps/desktop/renderer/index.jssubscribes tocmd:register,cmd:register-batch,noun:register-batch, and all core-managed domain topics during its synchronous init, BEFORE any tile is transitioned toloading. The main process waits for bgWindow'stile:lifecycle:ready(private IPC) before anyregistered → loadingtransition for other tiles. - Tile publishes are gated by token existence. A tile in
loadinghas no token yet (the token is minted onloading → ready). Any publish attempt from aloadingtile fails the chokepoint's token check and is rejected + logged asgate:rejected. No capability check is needed — there's no token to consult. - No replay needed. Because subscribers exist before any publisher
fires,
cmd:request-registersand theregisteredPayloadscache in tile-preload.cts become unnecessary. Delete both.
This is the single rule that kills the class of bug "only hello-world's commands visible in the cmd panel."
Load-on-dispatch — timeout UX #
When Core dispatches cmd:execute:X to a tile in registered, the
pre-publish hook forces a registered → loading → ready transition before
delivering. LAZY_LOAD_TIMEOUT_MS (currently 10s) bounds the wait.
On timeout (today: reject the pending promise with an error that the cmd panel surfaces as a spinner hang):
- Dispatcher promise rejects with a structured error.
- Core publishes
notification:showwith{ type: 'error', title: 'Tile didn't load', body: 'Command \"{name}\" couldn't run because its tile failed to load within 10 seconds.' }. - Cmd panel drops back to IDLE — no lingering spinner.
On render-process-gone during load (transition loading → crashed):
- Same notification, different body (
crashed while loading). - Dispatcher rejects immediately — no 10s wait.
Bypass detection #
Everything routed through the FSM is enforced at the gate; rejections
emit gate:rejected telemetry (see §Authorization rules). The only way
for a message to escape the FSM is for code to call a lower-level
Electron API directly, sidestepping the gate entirely. The FSM cannot
prevent this — anyone writing main-process code can do anything —
so we need complementary bypass detection.
Two bypass categories:
- Direct-send bypass —
webContents.send('pubsub:...')called outside the broadcaster. Mitigation: lint rule againstwebContents.sendwith apubsub:prefix, plus a dev-mode wrap ofwebContents.sendthat throws on the same pattern. - Off-path window creation —
new BrowserWindow()with apeek://{tileId}/...URL outside the tile launcher. Mitigation: lint rule + dev-mode assertion that every BrowserWindow with a tile URL has a correspondingregistered → loadingtransition. (Inherited from tile-lifecycle-fsm.md.)
Both are dev/CI-only assertions. Production has nothing to check — correct code can't trip them, and incorrect code is caught at review or in dev.
What is explicitly not tracked as rejection telemetry:
- Startup races (if they occur, the FSM design is wrong — fix the design, don't paper over it with a detector).
- Unrouted publishes (legitimate for domain events with no listeners).
Rejection emission rate limit: gate:rejected publishes are
rate-limited to one event per (tileId, reason) tuple per second, with
a dropped-count aggregator. A buggy or malicious tile spamming rejected
frames cannot amplify into a telemetry storm that saturates the
broadcaster.
Module layout #
Dependencies are one-way: tile-lifecycle → pubsub. pubsub never
imports from tile-lifecycle. This is what makes the pubsub FSM
testable in isolation and eliminates the race between state lookup and
state transition.
apps/desktop/main/pubsub-fsm.ts(pure, new) — the authorization matrix as a pure function:allow({role, grant, topic, op}) → 'allow' | {violation: reason}. No Electron / Node imports. Topic classification lives here. Testable without spinning up main.apps/desktop/main/tile-fsm.ts(pure, exists) — lifecycle transition table. Unchanged.apps/desktop/main/pubsub.ts— the hub: delivery, broadcaster wiring, pre-publish hooks. Importspubsub-fsm.tsonly. ExposesunsubscribeAllByPrefix(tileId)for lifecycle cleanup.apps/desktop/main/tile-ipc-gate.ts(new) — the single IPC chokepoint AND the rejection-telemetry surface AND the bypass detectors. One module owns the whole gate boundary:registerTileIpc(channel, handler, descriptor)— the only way to attach atile:*IPC handler. Runs the six-step validation sequence (see §The IPC chokepoint).emitGateRejected(reason, ctx)— rate-limited publish ofgate:rejected(one event per(tileId, reason)per second, dropped-count aggregator). Used by the gate itself.installDirectSendGuard()+installOffPathWindowGuard()— dev-mode monkey-patches catching the two bypass categories (§Bypass detection). Called at app init.- Subsumes what Phase 2 scaffolded as
tile-ipc-sender-check.ts— the sender-frame check becomes step 2 of the pipeline, not a separate helper module.
apps/desktop/main/tile-ipc.ts— individual channel handlers, registered viaregisterTileIpc. Ontile:pubsub:publish/tile:pubsub:subscribe: the gate has already validated the frame, so the handler just callspubsubFsm.allow(...)for per-topic authorization → publish or reject. This is where the lifecycle↔pubsub boundary sits.apps/desktop/main/tile-lifecycle.ts— state store + token lifecycle. Onloading → ready: mint token. On→ unloading/→ crashed: revoke token, callpubsub.unsubscribeAllByPrefix(). Publishestile:state-changedfor observers. Imports frompubsub.ts(one-way); never imported by it.apps/desktop/main/tile-lazy.ts— load-on-dispatch pre-publish hook. Already lives on the lifecycle side (it triggersregistered → loading). Continues to register as a pre-publish hook onpubsub.ts; the hook body calls into lifecycle.
Test boundaries:
pubsub-fsm.ts— pure unit tests over the authorization matrix.tile-fsm.ts— pure unit tests over the transition table (exists).tile-ipc.ts— integration tests with real tokens + real grants.- Lifecycle cleanup — integration test that
→ crashedclears the tile's subscriptions.
Phased implementation #
- Phase 1 — Fix bgWindow broadcast + rename broadcaster. Add
bgWindow to the broadcaster iteration. Rename
extensionBroadcaster→pubsubBroadcaster(the "extension" label is a stale v1 term — Peek now reserves "extension" for bundled Chromium extensions; features/tiles are the current vocabulary). This alone unblocks the "only hello-world visible" and "tile result doesn't reach subscribers" bugs. No semantic changes. Test: tag/untag/widget-update smoke tests go green. - Phase 2 — Sender-frame cross-check (security hardening).
Independently shippable. In every
tile:*handler, verifyevent.sendermatches theWebContentsthat ownspayload.token. Closes the "tile with XSS forges another tile's token" hole. Does not require the full IPC chokepoint (Phase 8) but prepares for it — this check is worth landing early because it's security, not cleanup. Test: new unit test that simulates a mismatched sender + valid token → rejected withgate:rejected. - Phase 3 — Collapse to one command-result path. Delete the
tile:command:resultIPC (preload send site + main-process handler). All command results flow through capability-gated pubsub publish of the result topic. Test: every test exercising command results stays green. - Phase 4 — Private lifecycle IPC + subscribe-before-publish.
Split
tile:ready/tile:shutdownoff the pubsub bus onto privatetile:lifecycle:*IPC channels handled directly intile-lifecycle.ts.apps/desktop/renderer/index.jswires all core subscribers during synchronous init; main process gates first tileregistered → loadingon bgWindow's private lifecycle-ready IPC. Addtile:state-changedas the public observer mirror. Test: unit test that simulates tile emittingcmd:registeratt=0boot — subscriber in core gets it. No pubsub-leveltile:readytopic remains. - Phase 5 — Delete replay machinery. Remove
cmd:request-registerstopic +registeredPayloadscache +ensureCmdRequestRegistersListenerin tile-preload.cts. Test: cmd registry has full contents after cold boot without replay. - Phase 6 — Delete scope. Remove
scopesconstant frompubsub.tsapi.scopessurface in tile-preload.api.publish/api.subscribelose their scope parameter. Migrate all ~100 call sites inapp/**- the 4 SYSTEM sites in the same commit; no backcompat shim.
Test: existing pubsub tests green; grep for
api.scopesreturns zero.
- Phase 7 — Timeout UX. Wire notification publish on
LAZY_LOAD_TIMEOUT_MSexpiry and onloading→crashedduring load-on-dispatch. Test: dispatcher to a tile whose preload throws → user-visible notification within 10s. - Phase 8 — IPC chokepoint + bypass detectors. Introduce
tile-ipc-gate.tsas the single module owning the gate boundary:registerTileIpc(channel, handler, descriptor)with the six-step validation pipeline (§The IPC chokepoint); rate-limitedgate:rejectedrejection telemetry; dev-mode bypass detectors (directwebContents.send('pubsub:...'), off-pathnew BrowserWindowfor tile URLs). Subsumes Phase 2'stile-ipc-sender-check.ts(folded in as step 2 of the pipeline). Migrate every existingtile:*ipcMain.onthroughregisterTileIpc. Add ESLint rules for the two bypass categories. Wire rejection events so CI fails on anygate:rejectedpublish during test. Test: regression suite stays green; a deliberately-seeded bypass in a test fixture trips the detector; everytile:*channel has at least one passing + one rejecting gate test; rate limiter test (100 rejections with same reason → one publish).
Each phase is independently shippable and each has a narrow failure mode.
Test plan #
- Pure FSM unit tests: state × operation matrix — each legal op
returns
allow, each illegal op returnsviolation. - Integration: cold boot → cmd panel has all declared commands before any tile window exists (manifest-cache path, already scoped by tile-lifecycle-fsm.md).
- Integration: cold boot → dispatch cmd to
registeredtile, assert load triggers, handler runs, result topic reaches dispatcher. No spinner-hang. - Integration: dispatch to tile whose renderer throws during init → notification appears, panel returns to IDLE within 10s.
- Regression: the 2026-04-23 repro
tests/desktop/cmd-execute-twice. spec.tsstill passes. Add companion that fires 5x in a row from the panel UI (per historical/tasks.md item). - Regression: full desktop suite stays green after each phase.
References #
- tile-lifecycle-fsm.md — tile state machine; this doc layers on top.
apps/desktop/main/pubsub.ts— the hub.apps/desktop/main/pubsub-fsm.ts(new, Phase 8) — pure authorization matrix.apps/desktop/main/tile-ipc.ts— individualtile:*channel handlers.apps/desktop/main/tile-ipc-gate.ts(new, Phase 8) — single IPC chokepoint + rejection telemetry + bypass detectors. Owns thegate:rejectedtopic.apps/desktop/main/tile-preload.cts— renderer-side publish/subscribe API + command registration.apps/desktop/main/main.ts:216— broadcaster registration site (setExtensionBroadcaster→ renamed tosetPubsubBroadcasterin Phase 1; this is the bug site for bgWindow exclusion).apps/desktop/main/tile-lazy.ts— pre-publish hook for load-on-dispatch.apps/desktop/renderer/background.html,apps/desktop/renderer/index.js,apps/desktop/renderer/cmd/background.js— core renderer entry points + subscribers.docs/historical/tasks.mdCurrent-Priority section — bugs this machine makes impossible.