# Blind Hunter Review Prompt - Story 1-5 Group 1 (Core Logic)

**Story:** 1-5-gm-control-ui-scryingpoolstrip-actionpopover-and-av-tile-integration  
**Group:** Core Logic (Group 1 of 4)  
**Files:** ScryingPoolController.js, VisibilityManager.js, and their tests  
**Diff lines:** 804

---

## YOUR ROLE: Blind Hunter

You are a **Blind Hunter** code reviewer. You receive **ONLY** the diff below — no spec, no context, no project access. Your job is to find security issues, bugs, anti-patterns, performance problems, and code quality issues using ONLY what you see in the diff.

### Rules:
- You have NO access to the spec file
- You have NO access to the project repository
- You have NO context about the project's goals
- You MUST find at least 5 issues
- Be adversarial: assume the code has problems and find them
- Focus on: security vulnerabilities, race conditions, error handling gaps, performance issues, code smells, test coverage gaps

### Output Format:
Output findings as a Markdown list. Each finding:
```markdown
- **SEV-XX: [One-line title]** — [Evidence from diff] — [Impact]
```

Classify severity:
- **SEV-CRITICAL**: Security vulnerability, data loss, crash
- **SEV-HIGH**: Race condition, resource leak, incorrect behavior
- **SEV-MEDIUM**: Code smell, maintainability issue
- **SEV-LOW**: Style, minor improvement

---

## DIFF TO REVIEW

diff --git a/src/core/ScryingPoolController.js b/src/core/ScryingPoolController.js
new file mode 100644
index 0000000..fc013f0
--- /dev/null
+++ b/src/core/ScryingPoolController.js
@@ -0,0 +1,181 @@
+/**
+ * ScryingPoolController — Orchestrates GM visibility actions with optimistic state updates.
+ *
+ * Handles: GM authorization, latest-revision-wins guard, last-intent guard, PendingOp
+ * lifecycle, optimistic setVisibility, socket emit, and echo reconciliation.
+ *
+ * Import rule: may only import from src/contracts/ and src/utils/.
+ * Constructors are side-effect free — call init() from module.js Hooks.once('ready').
+ *
+ * @module core/ScryingPoolController
+ */
+
+import { createPendingOp } from '../contracts/pending-op.js';
+import { createSocketIntentMessage, SOCKET_EVENTS } from '../contracts/socket-message.js';
+
+/**
+ * Orchestrates GM visibility actions: auth, optimistic state, socket emit, echo reconciliation.
+ */
+export class ScryingPoolController {
+  /**
+   * @param {import('./StateStore.js').StateStore} stateStore
+   * @param {{ emit(event: string, payload: object): void, registerPendingOp(op: object, event: string, payload: object): void, confirmPendingOp(opId: string): void, setReady(handler: object): void }} socketHandler
+   * @param {{ users: { isGM(): boolean }, socket: { on(event: string, handler: (...args: unknown[]) => void): void }, hooks: { on(event: string, handler: (...args: unknown[]) => void): void, callAll(event: string, data: unknown): void } }} adapter
+   */
+  constructor(stateStore, socketHandler, adapter) {
+    this._stateStore = stateStore;
+    this._socketHandler = socketHandler;
+    this._adapter = adapter;
+    /** @type {Map<string, import('../contracts/pending-op.js').PendingOp>} participantId → PendingOp */
+    this._pendingOps = new Map();
+    /** @type {Map<string, number>} participantId → last-confirmed revision */
+    this._revisions = new Map();
+  }
+
+  /**
+   * Registers the socket echo listener.
+   * Called from module.js Hooks.once('ready') — NOT from constructor.
+   */
+  init() {
+    this._adapter.socket.on(
+      SOCKET_EVENTS.VISIBILITY_UPDATED,
+      (payload) => this._onEcho(/** @type {any} */ (payload))
+    );
+  }
+
+  /**
+   * Returns the last confirmed revision for a participant (0 if unknown).
+   * @param {string} participantId
+   * @returns {number}
+   */
+  getRevision(participantId) {
+    return this._revisions.get(participantId) ?? 0;
+  }
+
+  /**
+   * Returns true if a pending op is currently in-flight for the given participant.
+   * @param {string} participantId
+   * @returns {boolean}
+   */
+  hasPendingOp(participantId) {
+    return this._pendingOps.has(participantId);
+  }
+
+  /**
+   * Cleans up a pending operation by userId.
+   * Called by SocketHandler timeout callback via composite handler in module.js.
+   * @param {string} userId
+   */
+  cleanupPendingOp(userId) {
+    this._pendingOps.delete(userId);
+  }
+
+  /**
+   * Processes a GM visibility toggle request.
+   * Guards: isGM, latest-revision-wins, last-intent (idempotent).
+   *
+   * @param {string} source - Who triggered the action (e.g. 'ui', 'preset').
+   * @param {string} participantId - Target userId.
+   * @param {string} targetState - Desired VisibilityState.
+   * @param {string} opId - Unique operation ID (supplied by caller — Story 1.5 UI).
+   * @param {number} baseRevision - StateStore revision at time of intent.
+   */
+  action(source, participantId, targetState, opId, baseRevision) {
+    // 0. Input validation
+    if (!participantId || typeof participantId !== 'string') {
+      console.warn('[ScryingPool]', 'ScryingPoolController.action: invalid participantId');
+      return;
+    }
+    if (!targetState || typeof targetState !== 'string') {
+      console.warn('[ScryingPool]', 'ScryingPoolController.action: invalid targetState');
+      return;
+    }
+    if (!opId || typeof opId !== 'string') {
+      console.warn('[ScryingPool]', 'ScryingPoolController.action: invalid opId');
+      return;
+    }
+    if (typeof baseRevision !== 'number' || !Number.isFinite(baseRevision) || baseRevision < 0) {
+      console.warn('[ScryingPool]', 'ScryingPoolController.action: invalid baseRevision');
+      return;
+    }
+
+    // 1. Authorization
+    if (!this._adapter.users.isGM()) {
+      console.warn('[ScryingPool]', 'ScryingPoolController.action: non-GM call rejected');
+      return;
+    }
+
+    // 2. Latest-revision-wins guard
+    const currentRevision = this._revisions.get(participantId) ?? 0;
+    if (baseRevision < currentRevision) return;
+
+    // 3. Last-intent guard (idempotent)
+    const currentState = this._stateStore.getState(participantId);
+    if (currentState === targetState) return;
+
+    // 4. Register PendingOp
+    const previousState = currentState ?? 'never-connected';
+    const pendingOp = createPendingOp(opId, participantId, targetState, previousState);
+    this._pendingOps.set(participantId, pendingOp);
+
+    // 5. Optimistic state update
+    this._stateStore.setVisibility(participantId, targetState);
+
+    // 6. Socket emit
+    const msg = createSocketIntentMessage(opId, participantId, targetState, baseRevision);
+    this._socketHandler.emit(msg.event, msg.payload);
+
+    // 7. Start acknowledgement timer
+    this._socketHandler.registerPendingOp(pendingOp, msg.event, msg.payload);
+
+    // 8. Notify UI subscribers
+    try {
+      this._adapter.hooks.callAll('scrying-pool:controllerAction', { participantId, targetState, source, opId });
+    } catch (hookErr) {
+      console.error('[ScryingPool] ScryingPoolController.action: hook emission failed', hookErr);
+    }
+  }
+
+  /**
+   * Processes an authoritative echo from the socket server.
+   * Confirms the pending op, updates revision, and sets the authoritative state.
+   * @private
+   * @param {{ opId: string, userId: string, state: string, revision?: number }} payload
+   */
+  _onEcho(payload) {
+    // Validate payload fields
+    if (!payload || typeof payload !== 'object') {
+      console.warn('[ScryingPool]', 'ScryingPoolController._onEcho: invalid payload');
+      return;
+    }
+    const { opId, userId, state, revision } = payload;
+    if (!opId || typeof opId !== 'string') {
+      console.warn('[ScryingPool]', 'ScryingPoolController._onEcho: missing or invalid opId');
+      return;
+    }
+    if (!userId || typeof userId !== 'string') {
+      console.warn('[ScryingPool]', 'ScryingPoolController._onEcho: missing or invalid userId');
+      return;
+    }
+    if (!state || typeof state !== 'string') {
+      console.warn('[ScryingPool]', 'ScryingPoolController._onEcho: missing or invalid state');
+      return;
+    }
+
+    this._socketHandler.confirmPendingOp(opId);
+    this._revisions.set(userId, revision ?? 0);
+    this._pendingOps.delete(userId);
+    this._stateStore.setVisibility(userId, state);
+
+    try {
+      this._adapter.hooks.callAll('scrying-pool:controllerAction', {
+        participantId: userId,
+        targetState: state,
+        source: 'echo',
+        opId,
+      });
+    } catch (hookErr) {
+      console.error('[ScryingPool] ScryingPoolController._onEcho: hook emission failed', hookErr);
+    }
+  }
+}
diff --git a/src/core/VisibilityManager.js b/src/core/VisibilityManager.js
new file mode 100644
index 0000000..0e465f2
--- /dev/null
+++ b/src/core/VisibilityManager.js
@@ -0,0 +1,104 @@
+/**
+ * VisibilityManager — WebRTC strategy applier and SocketHandler revert handler.
+ *
+ * Listens to `scrying-pool:stateChanged` hook events (emitted by StateStore) and
+ * applies the appropriate webrtcMode strategy:
+ *   - 'track-disable' + non-null adapter.webrtc → call disableTrack / enableTrack
+ *   - 'css-fallback' / 'unsupported' / null webrtc → no-op (CSS handled by RoleRenderer)
+ *
+ * Also implements onRevert(pendingOp) for SocketHandler timeout callbacks.
+ *
+ * Import rule: may only import from src/contracts/ and src/utils/.
+ * Constructors are side-effect free — call init() from module.js Hooks.once('ready').
+ *
+ * @module core/VisibilityManager
+ */
+
+/**
+ * Applies webrtcMode strategy on state changes and reverts failed operations.
+ */
+export class VisibilityManager {
+  /**
+   * @param {import('./StateStore.js').StateStore} stateStore
+   * @param {{ settings: { get(key: string): unknown }, webrtc: { disableTrack(userId: string): void, enableTrack(userId: string): void } | null, notifications: { warn(msg: string): void }, hooks: { on(event: string, handler: (...args: unknown[]) => void): void } }} adapter
+   */
+  constructor(stateStore, adapter) {
+    this._stateStore = stateStore;
+    this._adapter = adapter;
+  }
+
+  /**
+   * Registers the Hooks.on('scrying-pool:stateChanged') listener.
+   * Called from module.js Hooks.once('ready') — NOT from constructor.
+   */
+  init() {
+    this._adapter.hooks.on('scrying-pool:stateChanged', (data) => this._onStateChanged(/** @type {any} */ (data)));
+  }
+
+  /**
+   * Handles a state change by applying the webrtcMode strategy.
+   * css-fallback / unsupported → no-op (CSS applied by RoleRenderer in Story 1.5).
+   * track-disable + non-null webrtc → disable/enable the participant's track.
+   * Always safe with null adapter.webrtc (OQ-1 spike result for v14).
+   *
+   * @private
+   * @param {{ userId: string, state: string }} data
+   */
+  _onStateChanged(data) {
+    const { userId, state } = data;
+    // Input validation
+    if (!userId || typeof userId !== 'string') {
+      console.warn('[ScryingPool]', 'VisibilityManager._onStateChanged: invalid userId');
+      return;
+    }
+    if (!state || typeof state !== 'string') {
+      console.warn('[ScryingPool]', 'VisibilityManager._onStateChanged: invalid state');
+      return;
+    }
+
+    const mode = this._adapter.settings.get('webrtcMode');
+    if (mode !== 'track-disable' || !this._adapter.webrtc) return;
+    if (state === 'hidden') {
+      this._adapter.webrtc.disableTrack(userId);
+    } else {
+      this._adapter.webrtc.enableTrack(userId);
+    }
+  }
+
+  /**
+   * Called by SocketHandler after retry exhaustion — reverts the optimistic state
+   * and notifies the GM that the operation could not be confirmed.
+   *
+   * @param {{ userId: string, previousState: string, opId: string }} pendingOp
+   */
+  onRevert(pendingOp) {
+    // Input validation
+    if (!pendingOp || typeof pendingOp !== 'object') {
+      console.warn('[ScryingPool]', 'VisibilityManager.onRevert: invalid pendingOp');
+      return;
+    }
+    const { userId, previousState } = pendingOp;
+    if (!userId || typeof userId !== 'string') {
+      console.warn('[ScryingPool]', 'VisibilityManager.onRevert: invalid userId in pendingOp');
+      return;
+    }
+    if (!previousState || typeof previousState !== 'string') {
+      console.warn('[ScryingPool]', 'VisibilityManager.onRevert: invalid previousState in pendingOp');
+      return;
+    }
+
+    try {
+      this._stateStore.setVisibility(userId, previousState);
+    } catch (err) {
+      console.error('[ScryingPool] VisibilityManager.onRevert: setVisibility failed', err);
+    }
+
+    try {
+      this._adapter.notifications.warn(
+        `[ScryingPool] Visibility change for ${userId} could not be confirmed — reverting to ${previousState}`
+      );
+    } catch (err) {
+      console.error('[ScryingPool] VisibilityManager.onRevert: notification failed', err);
+    }
+  }
+}
diff --git a/tests/unit/core/ScryingPoolController.test.js b/tests/unit/core/ScryingPoolController.test.js
new file mode 100644
index 0000000..eb4f4ad
--- /dev/null
+++ b/tests/unit/core/ScryingPoolController.test.js
@@ -0,0 +1,277 @@
+// @ts-nocheck
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { ScryingPoolController } from '../../../src/core/ScryingPoolController.js';
+import { createFoundryAdapterMock } from '../../helpers/foundryAdapterMock.js';
+import { StateStore } from '../../../src/core/StateStore.js';
+
+/** @returns {{ emit: Function, registerPendingOp: Function, confirmPendingOp: Function, setReady: Function }} */
+function makeSocketHandler() {
+  return {
+    emit: vi.fn(),
+    registerPendingOp: vi.fn(),
+    confirmPendingOp: vi.fn(),
+    setReady: vi.fn(),
+  };
+}
+
+/** @returns {StateStore} */
+function makeStateStore() {
+  const settingsMock = {
+    get: vi.fn().mockReturnValue({ _version: 1, matrix: {} }),
+    set: vi.fn().mockResolvedValue(undefined),
+    register: vi.fn(),
+  };
+  return new StateStore(settingsMock);
+}
+
+describe('ScryingPoolController', () => {
+  let adapter;
+  let stateStore;
+  let socketHandler;
+  let controller;
+  let hooksStub;
+
+  beforeEach(() => {
+    hooksStub = { callAll: vi.fn(), on: vi.fn(), once: vi.fn(), off: vi.fn() };
+    vi.stubGlobal('Hooks', hooksStub);
+
+    adapter = createFoundryAdapterMock({ 
+      users: { isGM: () => true },
+      hooks: hooksStub
+    });
+    adapter.socket.on = vi.fn();
+
+    stateStore = makeStateStore();
+    socketHandler = makeSocketHandler();
+    controller = new ScryingPoolController(stateStore, socketHandler, adapter);
+  });
+
+  afterEach(() => {
+    vi.unstubAllGlobals();
+    vi.clearAllMocks();
+  });
+
+  // ── AC-1: Construction ────────────────────────────────────────────────────
+
+  describe('constructor (AC-1)', () => {
+    it('initialises _pendingOps as an empty Map', () => {
+      expect(controller._pendingOps).toBeInstanceOf(Map);
+      expect(controller._pendingOps.size).toBe(0);
+    });
+
+    it('initialises _revisions as an empty Map', () => {
+      expect(controller._revisions).toBeInstanceOf(Map);
+      expect(controller._revisions.size).toBe(0);
+    });
+
+    it('does NOT register socket listener in constructor (side-effect free)', () => {
+      expect(adapter.socket.on).not.toHaveBeenCalled();
+    });
+  });
+
+  // ── AC-1: init() ─────────────────────────────────────────────────────────
+
+  describe('init() (AC-1)', () => {
+    it('registers socket echo listener for scrying-pool.visibility.updated', () => {
+      controller.init();
+      expect(adapter.socket.on).toHaveBeenCalledWith(
+        'scrying-pool.visibility.updated',
+        expect.any(Function)
+      );
+    });
+  });
+
+  // ── AC-2: action() happy path ─────────────────────────────────────────────
+
+  describe('action() happy path (AC-2)', () => {
+    it('stores a PendingOp in _pendingOps keyed by participantId', () => {
+      controller.action('ui', 'user-1', 'hidden', 'op-1', 0);
+      expect(controller._pendingOps.has('user-1')).toBe(true);
+      expect(controller._pendingOps.get('user-1')).toMatchObject({
+        opId: 'op-1',
+        userId: 'user-1',
+        targetState: 'hidden',
+      });
+    });
+
+    it('calls stateStore.setVisibility with the target state (optimistic update)', () => {
+      const setSpy = vi.spyOn(stateStore, 'setVisibility');
+      controller.action('ui', 'user-1', 'hidden', 'op-1', 0);
+      expect(setSpy).toHaveBeenCalledWith('user-1', 'hidden');
+    });
+
+    it('calls socketHandler.emit with VISIBILITY_SET event and correct payload', () => {
+      controller.action('ui', 'user-1', 'hidden', 'op-1', 0);
+      expect(socketHandler.emit).toHaveBeenCalledWith(
+        'scrying-pool.visibility.set',
+        expect.objectContaining({ opId: 'op-1', userId: 'user-1', targetState: 'hidden', baseRevision: 0 })
+      );
+    });
+
+    it('calls socketHandler.registerPendingOp with the PendingOp, event, and payload', () => {
+      controller.action('ui', 'user-1', 'hidden', 'op-1', 0);
+      expect(socketHandler.registerPendingOp).toHaveBeenCalledWith(
+        expect.objectContaining({ opId: 'op-1', userId: 'user-1', targetState: 'hidden' }),
+        'scrying-pool.visibility.set',
+        expect.objectContaining({ opId: 'op-1' })
+      );
+    });
+
+    it('fires Hooks.callAll scrying-pool:controllerAction with correct payload', () => {
+      controller.action('ui', 'user-1', 'hidden', 'op-1', 0);
+      expect(hooksStub.callAll).toHaveBeenCalledWith(
+        'scrying-pool:controllerAction',
+        expect.objectContaining({ participantId: 'user-1', targetState: 'hidden', source: 'ui', opId: 'op-1' })
+      );
+    });
+
+    it('sets previousState to null-coalesced "never-connected" when participant is new', () => {
+      controller.action('ui', 'new-user', 'hidden', 'op-1', 0);
+      const op = controller._pendingOps.get('new-user');
+      expect(op.previousState).toBe('never-connected');
+    });
+  });
+
+  // ── AC-5: non-GM authorization ────────────────────────────────────────────
+
+  describe('action() non-GM authorization (AC-5)', () => {
+    it('warns and silently drops the action when adapter.users.isGM() is false', () => {
+      const nonGmAdapter = createFoundryAdapterMock({ 
+        users: { isGM: () => false },
+        hooks: hooksStub
+      });
+      nonGmAdapter.socket.on = vi.fn();
+      const playerController = new ScryingPoolController(stateStore, socketHandler, nonGmAdapter);
+      const setSpy = vi.spyOn(stateStore, 'setVisibility');
+      const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
+
+      playerController.action('ui', 'user-1', 'hidden', 'op-1', 0);
+
+      expect(warnSpy).toHaveBeenCalledWith('[ScryingPool]', expect.stringContaining('non-GM'));
+      expect(setSpy).not.toHaveBeenCalled();
+      expect(socketHandler.emit).not.toHaveBeenCalled();
+      expect(socketHandler.registerPendingOp).not.toHaveBeenCalled();
+      expect(hooksStub.callAll).not.toHaveBeenCalled();
+
+      warnSpy.mockRestore();
+    });
+  });
+
+  // ── AC-3: latest-revision-wins guard ─────────────────────────────────────
+
+  describe('action() latest-revision-wins guard (AC-3)', () => {
+    it('silently drops action when baseRevision < confirmed revision', () => {
+      controller._revisions.set('user-1', 5);
+      const setSpy = vi.spyOn(stateStore, 'setVisibility');
+
+      controller.action('ui', 'user-1', 'hidden', 'op-2', 3); // 3 < 5 → stale
+
+      expect(setSpy).not.toHaveBeenCalled();
+      expect(socketHandler.emit).not.toHaveBeenCalled();
+      expect(hooksStub.callAll).not.toHaveBeenCalled();
+    });
+
+    it('allows action when baseRevision equals confirmed revision (not stale)', () => {
+      controller._revisions.set('user-1', 5);
+      const setSpy = vi.spyOn(stateStore, 'setVisibility');
+
+      controller.action('ui', 'user-1', 'hidden', 'op-2', 5); // 5 == 5 → not stale
+
+      expect(setSpy).toHaveBeenCalledWith('user-1', 'hidden');
+    });
+
+    it('allows action with baseRevision=0 when no revision confirmed yet', () => {
+      const setSpy = vi.spyOn(stateStore, 'setVisibility');
+
+      controller.action('ui', 'user-1', 'hidden', 'op-1', 0);
+
+      expect(setSpy).toHaveBeenCalled();
+    });
+  });
+
+  // ── AC-4: last-intent guard ───────────────────────────────────────────────
+
+  describe('action() last-intent guard (AC-4)', () => {
+    it('silently drops action when participant is already in targetState', () => {
+      // Seed the state store with the current state
+      stateStore.setVisibility('user-1', 'hidden');
+      vi.clearAllMocks(); // reset all mock call counts
+
+      const setSpy = vi.spyOn(stateStore, 'setVisibility');
+
+      controller.action('ui', 'user-1', 'hidden', 'op-2', 0);
+
+      expect(setSpy).not.toHaveBeenCalled();
+      expect(socketHandler.emit).not.toHaveBeenCalled();
+    });
+
+    it('allows action when targetState differs from current state', () => {
+      stateStore.setVisibility('user-1', 'active');
+      vi.clearAllMocks();
+
+      const setSpy = vi.spyOn(stateStore, 'setVisibility');
+
+      controller.action('ui', 'user-1', 'hidden', 'op-3', 0);
+
+      expect(setSpy).toHaveBeenCalledWith('user-1', 'hidden');
+    });
+  });
+
+  // ── AC-11: echo reconciliation (_onEcho) ──────────────────────────────────
+
+  describe('_onEcho() echo reconciliation (AC-11)', () => {
+    // Helper: call init() and return the captured echo handler
+    function getEchoHandler() {
+      controller.init();
+      return adapter.socket.on.mock.calls[0][1];
+    }
+
+    it('calls socketHandler.confirmPendingOp with the opId', () => {
+      const echoHandler = getEchoHandler();
+      echoHandler({ opId: 'op-1', userId: 'user-1', state: 'hidden', revision: 1 });
+      expect(socketHandler.confirmPendingOp).toHaveBeenCalledWith('op-1');
+    });
+
+    it('stores the echo revision in _revisions for the userId', () => {
+      const echoHandler = getEchoHandler();
+      echoHandler({ opId: 'op-1', userId: 'user-1', state: 'hidden', revision: 7 });
+      expect(controller._revisions.get('user-1')).toBe(7);
+    });
+
+    it('calls stateStore.setVisibility with the authoritative state', () => {
+      const echoHandler = getEchoHandler();
+      const setSpy = vi.spyOn(stateStore, 'setVisibility');
+
+      echoHandler({ opId: 'op-1', userId: 'user-1', state: 'active', revision: 2 });
+
+      expect(setSpy).toHaveBeenCalledWith('user-1', 'active');
+    });
+
+    it('fires Hooks.callAll scrying-pool:controllerAction with source: echo', () => {
+      const echoHandler = getEchoHandler();
+      echoHandler({ opId: 'op-1', userId: 'user-1', state: 'hidden', revision: 1 });
+
+      expect(hooksStub.callAll).toHaveBeenCalledWith(
+        'scrying-pool:controllerAction',
+        expect.objectContaining({ source: 'echo', participantId: 'user-1', targetState: 'hidden', opId: 'op-1' })
+      );
+    });
+
+    it('removes the participant from _pendingOps after echo', () => {
+      // Register a pending op first
+      controller.action('ui', 'user-1', 'hidden', 'op-1', 0);
+      expect(controller._pendingOps.has('user-1')).toBe(true);
+
+      const echoHandler = getEchoHandler();
+      echoHandler({ opId: 'op-1', userId: 'user-1', state: 'hidden', revision: 1 });
+
+      expect(controller._pendingOps.has('user-1')).toBe(false);
+    });
+
+    it('defaults revision to 0 when echo payload omits revision field', () => {
+      const echoHandler = getEchoHandler();
+      echoHandler({ opId: 'op-1', userId: 'user-1', state: 'hidden' }); // no revision
+      expect(controller._revisions.get('user-1')).toBe(0);
+    });
+  });
+});
diff --git a/tests/unit/core/VisibilityManager.test.js b/tests/unit/core/VisibilityManager.test.js
new file mode 100644
index 0000000..36df70f
--- /dev/null
+++ b/tests/unit/core/VisibilityManager.test.js
@@ -0,0 +1,218 @@
+// @ts-nocheck
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { VisibilityManager } from '../../../src/core/VisibilityManager.js';
+import { createFoundryAdapterMock } from '../../helpers/foundryAdapterMock.js';
+import { StateStore } from '../../../src/core/StateStore.js';
+
+/** @returns {StateStore} */
+function makeStateStore() {
+  const settingsMock = {
+    get: vi.fn().mockReturnValue({ _version: 1, matrix: {} }),
+    set: vi.fn().mockResolvedValue(undefined),
+    register: vi.fn(),
+  };
+  return new StateStore(settingsMock);
+}
+
+describe('VisibilityManager', () => {
+  let adapter;
+  let stateStore;
+  let manager;
+  let hooksStub;
+
+  beforeEach(() => {
+    hooksStub = { callAll: vi.fn(), on: vi.fn(), once: vi.fn(), off: vi.fn() };
+    vi.stubGlobal('Hooks', hooksStub);
+
+    adapter = createFoundryAdapterMock({ hooks: hooksStub });
+    stateStore = makeStateStore();
+    manager = new VisibilityManager(stateStore, adapter);
+  });
+
+  afterEach(() => {
+    vi.unstubAllGlobals();
+    vi.clearAllMocks();
+  });
+
+  // ── AC-1 (construction side-effect free) ─────────────────────────────────
+
+  describe('constructor (side-effect free)', () => {
+    it('does NOT register Hooks.on listener in constructor', () => {
+      expect(hooksStub.on).not.toHaveBeenCalled();
+    });
+  });
+
+  // ── init() ────────────────────────────────────────────────────────────────
+
+  describe('init()', () => {
+    it('registers Hooks.on for scrying-pool:stateChanged', () => {
+      manager.init();
+      expect(hooksStub.on).toHaveBeenCalledWith(
+        'scrying-pool:stateChanged',
+        expect.any(Function)
+      );
+    });
+  });
+
+  // ── AC-6: _onStateChanged — track-disable strategy ────────────────────────
+
+  describe('_onStateChanged() track-disable strategy (AC-6)', () => {
+    let webrtcMock;
+
+    beforeEach(() => {
+      webrtcMock = { disableTrack: vi.fn(), enableTrack: vi.fn() };
+      const trackDisableAdapter = createFoundryAdapterMock({
+        webrtc: webrtcMock,
+        settings: { get: (key) => (key === 'webrtcMode' ? 'track-disable' : null) },
+        hooks: hooksStub,
+      });
+      manager = new VisibilityManager(stateStore, trackDisableAdapter);
+      manager.init();
+    });
+
+    it('calls disableTrack(userId) when state is hidden', () => {
+      const handler = hooksStub.on.mock.calls[0][1];
+      handler({ userId: 'user-1', state: 'hidden' });
+      expect(webrtcMock.disableTrack).toHaveBeenCalledWith('user-1');
+      expect(webrtcMock.enableTrack).not.toHaveBeenCalled();
+    });
+
+    it('calls enableTrack(userId) when state is active', () => {
+      const handler = hooksStub.on.mock.calls[0][1];
+      handler({ userId: 'user-1', state: 'active' });
+      expect(webrtcMock.enableTrack).toHaveBeenCalledWith('user-1');
+      expect(webrtcMock.disableTrack).not.toHaveBeenCalled();
+    });
+  });
+
+  // ── AC-7: _onStateChanged — css-fallback / unsupported ────────────────────
+
+  describe('_onStateChanged() css-fallback strategy (AC-7)', () => {
+    it('performs no webrtc call and throws no error when mode is css-fallback', () => {
+      const cssFallbackAdapter = createFoundryAdapterMock({
+        settings: { get: (key) => (key === 'webrtcMode' ? 'css-fallback' : null) },
+        hooks: hooksStub,
+      });
+      manager = new VisibilityManager(stateStore, cssFallbackAdapter);
+      manager.init();
+
+      const handler = hooksStub.on.mock.calls[0][1];
+      expect(() => handler({ userId: 'user-1', state: 'hidden' })).not.toThrow();
+    });
+
+    it('performs no webrtc call and throws no error when mode is unsupported', () => {
+      const unsupportedAdapter = createFoundryAdapterMock({
+        settings: { get: (key) => (key === 'webrtcMode' ? 'unsupported' : null) },
+        hooks: hooksStub,
+      });
+      manager = new VisibilityManager(stateStore, unsupportedAdapter);
+      manager.init();
+
+      const handler = hooksStub.on.mock.calls[0][1];
+      expect(() => handler({ userId: 'user-1', state: 'hidden' })).not.toThrow();
+    });
+  });
+
+  // ── AC-10: null webrtc guard ──────────────────────────────────────────────
+
+  describe('_onStateChanged() null webrtc guard (AC-10)', () => {
+    it('does not throw when adapter.webrtc is null in track-disable mode', () => {
+      const nullWebrtcAdapter = createFoundryAdapterMock({
+        webrtc: null,
+        settings: { get: (key) => (key === 'webrtcMode' ? 'track-disable' : null) },
+        hooks: hooksStub,
+      });
+      manager = new VisibilityManager(stateStore, nullWebrtcAdapter);
+      manager.init();
+
+      const handler = hooksStub.on.mock.calls[0][1];
+      expect(() => handler({ userId: 'user-1', state: 'hidden' })).not.toThrow();
+    });
+
+    it('does not throw when adapter.webrtc is null with state active', () => {
+      const nullWebrtcAdapter = createFoundryAdapterMock({
+        webrtc: null,
+        settings: { get: (key) => (key === 'webrtcMode' ? 'track-disable' : null) },
+        hooks: hooksStub,
+      });
+      manager = new VisibilityManager(stateStore, nullWebrtcAdapter);
+      manager.init();
+
+      const handler = hooksStub.on.mock.calls[0][1];
+      expect(() => handler({ userId: 'user-1', state: 'active' })).not.toThrow();
+    });
+  });
+
+  // ── AC-9: onRevert() ─────────────────────────────────────────────────────
+
+  describe('onRevert() (AC-9)', () => {
+    /** @type {import('../../../src/contracts/pending-op.js').PendingOp} */
+    const pendingOp = {
+      opId: 'op-1',
+      userId: 'user-1',
+      targetState: 'hidden',
+      previousState: 'active',
+      issuedAt: 1000000,
+      timeoutId: null,
+    };
+
+    it('calls stateStore.setVisibility with previousState to revert', () => {
+      const setSpy = vi.spyOn(stateStore, 'setVisibility');
+      manager.onRevert(pendingOp);
+      expect(setSpy).toHaveBeenCalledWith('user-1', 'active');
+    });
+
+    it('calls adapter.notifications.warn with a [ScryingPool]-prefixed message', () => {
+      const warnMock = vi.fn();
+      const warnAdapter = createFoundryAdapterMock({
+        notifications: { warn: warnMock, info: () => {}, error: () => {} },
+        hooks: hooksStub,
+      });
+      manager = new VisibilityManager(stateStore, warnAdapter);
+
+      manager.onRevert(pendingOp);
+
+      expect(warnMock).toHaveBeenCalledOnce();
+      expect(warnMock.mock.calls[0][0]).toMatch(/^\[ScryingPool\]/);
+    });
+
+    it('includes userId in the warning message', () => {
+      const warnMock = vi.fn();
+      const warnAdapter = createFoundryAdapterMock({
+        notifications: { warn: warnMock, info: () => {}, error: () => {} },
+        hooks: hooksStub,
+      });
+      manager = new VisibilityManager(stateStore, warnAdapter);
+
+      manager.onRevert(pendingOp);
+
+      expect(warnMock.mock.calls[0][0]).toContain('user-1');
+    });
+
+    it('does NOT call notifications.info (no success notification on revert)', () => {
+      const infoMock = vi.fn();
+      const noInfoAdapter = createFoundryAdapterMock({
+        notifications: { warn: () => {}, info: infoMock, error: () => {} },
+        hooks: hooksStub,
+      });
+      manager = new VisibilityManager(stateStore, noInfoAdapter);
+
+      manager.onRevert(pendingOp);
+
+      expect(infoMock).not.toHaveBeenCalled();
+    });
+
+    it('does NOT call notifications.error', () => {
+      const errorMock = vi.fn();
+      const noErrorAdapter = createFoundryAdapterMock({
+        notifications: { warn: () => {}, info: () => {}, error: errorMock },
+        hooks: hooksStub,
+      });
+      manager = new VisibilityManager(stateStore, noErrorAdapter);
+
+      manager.onRevert(pendingOp);
+
+      expect(errorMock).not.toHaveBeenCalled();
+    });
+  });
+});
