# Console-v2-fix-build harness deferrals (2026-05-04)

**Captured:** 2026-05-04 (auto-extracted from harness build of `BUILD_SPEC.md`)

## Status: STUB — JT must review and either:
- (a) convert each item to a substantive rejection per Law 14 (durable reasons, not "not now"), OR
- (b) promote to a follow-up build, OR
- (c) delete if not actually meaningful

The harness ran 7 build phases + 5 debug rounds (Gemini + Execution + Opus + Gemini + Execution). Each phase's `/simplify` quality pass and each debug round produced findings beyond what was in scope for that phase. The substantive ones were applied inline; the rest landed here.

This file groups them by category. Each category could become its own `.out-of-scope/` entry (per Law 14) once JT decides which are real "rejections" vs "deferred to next build."

---

## Architectural — actually load-bearing for next build

### A1. Break the schemas/engine ↔ system_status import cycle properly
Current state: `recoil/api/schemas/engine.py` bottom-imports from `system_status.py` (for codegen single-module discovery). `system_status.py` lazy-imports `_COUNTERS` from `sanctioned_fallbacks` *inside* `_get_status()` because eager import creates a cycle (eventbus → schemas/engine → system_status → sanctioned_fallbacks → eventbus, with BUS undefined mid-import).

The lazy import is a workaround for a workaround. Proper fix: extract `SCHEMA_VERSION` + `_Versioned` to `recoil/api/schemas/_base.py`. `system_status.py` imports from `_base`; cycle vanishes; lazy import becomes module-level.

**Why deferred:** P4 quality pass risk-graded this as too touchy for the in-flight build. Touches base infrastructure; future P5/P6/P7 depended on the workaround. Now that the build is committed, this can land cleanly in a follow-up.

**Effort:** ~30 min. **Risk:** low once P5+ lands.

### A2. `_is_known_proposal_id` leaks `stub_routes` internals into `proposal_dispatch.py`
Current state: `proposal_dispatch.emit_decision` does a local import of `stub_routes` to call `_is_known_proposal_id` (combines `_pending_items()` + `_proposal_kind_in_acted`). The helper depends on a load-bearing call ordering (route adds id to acted bucket BEFORE emit_decision runs) — long-distance contract waiting to break.

Better shape: pass `was_pending: bool` as an explicit parameter to `emit_decision`. The route handler in `mutation_routes.py` calls `stub_routes._is_known_proposal_id(id)` BEFORE `_acted_add` AND BEFORE `emit_decision`, then forwards the boolean. No leaky import; ordering becomes explicit not implicit.

**Why deferred:** P2 quality pass — works correctly today; refactor risk > benefit at the time. With the pattern now stable across mutation_routes + proposal_dispatch + stub_routes, the inversion is a tractable cleanup.

### A3. `findFocusPath` walker — consolidate 3 project-tree walks
Current state: `App.tsx` has two near-identical project-tree walks (`focusedProjectAspect` line 303-318, `focusedProject` line 325-340). `useBreadcrumb` adds a third walk in `lib/use_breadcrumb.ts`. All three iterate `projects → episodes → scenes → beatList` looking for the focused id.

Consolidate into a single `findFocusPath(focused, projects): {project, episode, scene, beat} | null` walker in `lib/focus_walker.ts`. Each consumer derives what it needs from the path object.

**Why deferred:** P3 quality pass — App.tsx is sensitive (15 useState hooks, 4 banner tests, modal logic). Refactor would touch a load-bearing surface area mid-build. Easier to land standalone after the build commits.

---

## Performance — premature today, real at scale

### P1. Cheap `_episode_ids_set(project_id) -> set[str]` helper
Current state: `list_beats(project_id, episode_id, scene_id)` calls `_synthesize_episodes(project_id)` purely to verify `episode_id` exists, then walks shot files AGAIN to filter beats. Two full directory walks per `/beats` request. Same in `list_scenes`.

Better shape: extract `_episode_ids_set` that walks shots once and yields just episode_id strings (no Pydantic construction). Use it for the existence check; halves I/O on `list_beats` and `list_scenes`.

**Why deferred:** Spec deferred LRU caching to CP-N+. At Tartarus scale (~61 shots, ~20ms walk) this is invisible; at 500+ shots becomes the hot path. Pairs with the deferred LRU work — same code path, batched fix.

### P2. CommandPalette `getSlashCommands()` re-fetches on every ⌘K open
Current state: `useEffect(() => { adapter.getSlashCommands().then(setCommands) }, [])` runs on every CommandPalette mount. Mount happens every ⌘K open (modal pattern). The fixture impl returns a constant; the http-adapter hits `/api/commands-ref`.

Better shape: module-level cached promise in `data.ts`:
```ts
let commandsPromise: Promise<SlashCommand[]> | null = null;
export const getCachedSlashCommands = () => commandsPromise ??= adapter.getSlashCommands();
```

**Why deferred:** P4 simplify — UX-bounded; re-fetch cost is one round-trip per palette open, ~50ms. Real opportunity if JT keeps slamming ⌘K. NIT today.

### P3. `useSystemStatus` re-renders all chrome on every 5s poll
Current state: every `setStatus(s)` re-renders Titlebar/StatusBar/StatusPopover/BottomBay even when only `uptime_seconds` changed.

Better shape: split `uptimeSeconds` into a separate `useState` that ticks at the polling interval; short-circuit the heavy `status` object on shallow equality of the rest. OR memo the chrome consumers behind `React.memo`.

**Why deferred:** P4 simplify — premature for current scale; chrome re-render is sub-ms. Promote when profiler shows it.

---

## Frontend — UX/a11y polish

### F1. SessionRestoreModal — focus management + Escape handler + focus trap
Current state: modal has `role="dialog"` + `aria-modal="true"` + `aria-labelledby` but: no focus-on-mount (browser picks `<body>`); no Escape handler; tab-key focus can leak to elements behind the backdrop. Existing `EventsDrawer`, `CommandPalette`, `StatusPopover` all attach Escape — this one diverges.

Fix: `useEffect(() => buttonRef.current?.focus(), [])` on the Discard button; trap Tab to the two buttons + `<details>` summary; Escape-as-no-op (modal is hard-stop) OR Escape-as-Discard-confirm.

**Why deferred:** P5 simplify — a11y regression vs project pattern but no production exploit. JT picks the Escape semantics.

### F2. `actionError` interpolation leaks `String(err)` 
Current state: `setActionError(\`discard failed: ${String(err)}\`)` — Zod errors stringify with full path detail; Error.stack-bearing messages leak internals.

Fix: `setActionError("discard failed — see console for detail"); console.error("discard failed", err);`

**Why deferred:** P5 simplify — power-user moment, not a marketing surface. NIT.

### F3. Mobile empty-state polish
Current state: P4's stale-string sweep replaced "tab body arrives in a later phase" + "Phase 14 wires…" with a thin "no project selected" message. Looks more like a render failure than an intentional empty state.

Fix: small icon + "select a project" secondary action; or a deliberate pattern like the desktop empty states use.

**Why deferred:** P4 — pure UX polish; functionality unchanged.

### F4. Connection-state CSS literals
Current state: TS type `"connected" | "degraded" | "disconnected"` is exported and re-imported. CSS class names `.connected / .degraded / .disconnected` and test case strings reference the literals separately. TS catches `Record<ConnectionState, ...>` at compile time but does NOT catch CSS class names or test strings.

Fix: export `CONNECTION_STATES: readonly ConnectionState[] = ["connected","degraded","disconnected"] as const`; iterate in tests; pin CSS surface with a comment.

**Why deferred:** P4 simplify — works today; rename pressure is low.

### F5. `eslint-disable react-hooks/exhaustive-deps` with explicit WHY in `useSystemStatus`
Current state: `useEffect(() => {...}, [])` — empty deps. Polling cadence is independent of `sseStatus`. Lint will warn (or did before silenced); next maintainer might "fix" it and break the contract.

Fix: explicit `// eslint-disable-next-line react-hooks/exhaustive-deps` + one-line WHY ("polling cadence is independent of sseStatus; connection derives in render").

**Why deferred:** P4 simplify — NIT, but cheap to land.

### F6. `useDebouncedSave`: `setBusy(false)` not reset on success
Current state: `SessionRestoreModal` sets `busy=true` then awaits adapter call; on success calls `onResolved(...)` and never resets `busy`. Works because `onResolved` triggers `setParseError(null)` which unmounts the modal. Brittle.

Fix: `try { ... ; onResolved(...) } finally { setBusy(false); }`

**Why deferred:** P5 simplify — works today via parent unmount; brittle to future changes (e.g., a confirmation step that doesn't unmount).

---

## Backend — defensive hardening

### B1. `CorruptStateReport` body-size limits
Current state: `payload_json` and `zod_error` are unbounded strings on the `/api/workspace-state/{wid}/report` POST. FastAPI/Starlette has no default body-size limit. A malicious or buggy client can POST arbitrarily large payloads → JSONL append explodes.

Fix: `Field(max_length=...)` constraints — e.g., 1 MiB on `payload_json`, 16 KiB on `zod_error`. Surface 422 on overflow.

**Why deferred:** R3 Opus audit (LOW). Trusted Tailscale mesh today; not exploitable. Defensive hardening.

### B2. `report_path` returned to client leaks server filesystem layout
Current state: `/api/workspace-state/{wid}/report` returns `{"ok": True, "report_path": "/Users/joeturnerlin/.recoil/v2_corrupt_state_reports.jsonl"}` — leaks home dir to the client.

Fix: return a stable token like `{"ok": True, "recorded": true}` (or a relative form). Operators inspect the JSONL via terminal, not via the API response.

**Why deferred:** P5 simplify — single-machine deployment; not a real concern today. If recoil ever runs on a shared host, becomes path-disclosure noise.

### B3. `_REPORTS_PATH.parent.mkdir(parents=True, exist_ok=True)` per call
Current state: runs on every POST. Cheap (single stat-call when dir exists) but redundant after first call.

Fix: cache a module-level boolean. NIT.

**Why deferred:** P5 simplify.

### B4. `jsonl_append_locked` helper reuse
Current state: `recoil/pipeline/lib/jsonl_append.py` exists and is used by `pipeline/lib/ops_log.py`, `pipeline/lib/review_queue.py`, `pipeline/core/dispatch.py`. The new `workspace_state_report.py` rolls its own fcntl + fsync (R1 fix added these inline).

Fix: import the helper from pipeline. Cross-engine import boundary — would establish a new pattern (`recoil/api/` → `recoil/pipeline/lib/`). Alternative: move helper to `recoil/core/` shared location.

**Why deferred:** P5 simplify — adopting the helper requires a boundary decision JT should make.

---

## Test infrastructure

### T1. `recoil/api/adapters/projects.py` pre-existing F401 unused imports (`Beat`, `Episode`, `Scene`, `_PROJECT_ID_RE_SHARED`, `MemoryKind`)
Pre-existing before harness run. P3 will need `Episode` once `_synthesize_episodes` populates from the schema (already done). `Beat` and `Scene` may now be referenced. Re-check if any are truly unused; clean up the rest.

**Why deferred:** Pre-existing; not P-build-introduced; out of phase scope.

### T2. App.test.tsx fetch stub matches `/api/workspace/` substring vs route `/api/workspace-state/`
Current state: stub falls through to `[]` for the wid endpoint. Test response triggers parse-failure-default fallback on every test run, which after R1's sentinel-string fix produces `Error: payload_json is not valid JSON: Unexpected token '<'` in test stderr. NOT a test failure (all 89 desktop tests pass). Latent surprise for any future test that asserts modal presence/absence.

Fix: tighten the stub matcher OR explicitly assert "modal does NOT mount on test fixture data."

**Why deferred:** P5 build agent flagged. No assertion currently checks; not breaking.

### T3. Three `FakeEventSource` test boilerplate copies in App.test.tsx
Current state: `FakeEventSource` class + listeners map + globalThis assignment duplicated across 3 banner tests (~50 lines each).

Fix: `installFakeEventSource()` helper in a test-utility module; hoist to `beforeEach`.

**Why deferred:** P2 simplify — tests pass; refactor-only cleanup.

---

## Documentation cleanup (NIT, low priority)

### D1. Phase narration scattered across docstrings
Many docblocks across all 7 phases mention "P{N} (console-v2-fix)" as build-log spillover. The genuinely useful prose (Law 12 substrate rationale, etc.) should stay; the temporal "P{N}" tags should drop. git blame is the source of truth for "when was this added."

Files: `system_status.py`, `use_system_status.ts`, `use_breadcrumb.ts`, `Titlebar.tsx`, `StatusBar.tsx`, `StatusPopover.tsx`, `BottomBay.tsx`, `ChromeTop.tsx`, `ArtifactStage.tsx`, `main.py`, `sanctioned_fallbacks.py`, `SessionRestoreModal.tsx`, `workspace_state_report.py`, `shell.css`, `test_mutation_routes.py`, `App.test.tsx`, others.

**Why deferred:** All phases — pervasive NIT; scriptable cleanup pass post-build.

### D2. `list_episodes` one-liner pass-through to `_synthesize_episodes` in beats.py
Current state: `list_episodes(project_id) -> list[Episode]: return _synthesize_episodes(project_id)`. The wrapping function adds nothing. Same name conflict the diff worked around could be solved by exporting `_synthesize_episodes` as `list_episodes`.

**Why deferred:** P3 simplify — minor; documentation noise only.

---

## CommandPalette pre-existing bug

### C1. ArrowDown with 0 search results
Current state: `CommandPalette.tsx:89` — when search yields 0 results, ArrowDown evaluates `Math.min(-1, i+1)` = -1, breaking keyboard nav (`flat[-1]` is undefined).

Fix: guard `if (flat.length === 0) return;` early in the keyboard handlers.

**Why deferred:** R1 Gemini flagged. Pre-existing (not P4-introduced — P4 wired ⌘K to adapter.getSlashCommands but didn't touch keyboard handlers). Out of harness build scope.

---

## How to use this file

JT review checklist:
- [ ] For each item, decide: real rejection (Law 14) → split into its own `.out-of-scope/{slug}.md` with substantive prose; OR deferred-to-next-build → file in a follow-up; OR delete if it's not actually meaningful.
- [ ] Architectural items (A1-A3) likely warrant their own ADRs once decided.
- [ ] Performance items (P1-P3) all pair with the deferred LRU work; bundle when LRU lands.
- [ ] Backend hardening items (B1-B4) are defensive — group into a "console-v2 hardening pass" follow-up or accept as-is on the trusted-mesh assumption.
