# Cascade GUI dedup — finishing Phase C The full L0→L4 cascade now lives in `joule_core::cascade::CascadeEngine` and is already used by the headless worker (`joule-coded serve` + the `cascade` subcommand) and `joule-bench`. The GUI panel (`agent_joule_panel`) still carries its own inline copy of the tier orchestration. This doc is the plan to delete that copy and route the panel through the shared engine. **Why this step is staged separately:** it changes the editor's async interaction path, and GPUI failure modes (UI freeze, borrow-panics, a missed `cx.notify()`) do **not** show at compile time. It must be verified with the editor running. **Already done (compile + test verified, no editor needed):** - The pure-helper slice — `parse_substitution`, `unquote`, `best_l2_match` are shared from `joule_core::cascade` (commit `f9c9b02`). - The engine-side adapter pieces this plan calls for: `joule_core::document::StringDocument` (in-memory snapshot Document), `CascadeEngine::resolve_local` (L0–L3, never consults the cloud, returns `tier: None` on fall-through), and `CascadeEngine::record_external_close` (record a GUI-applied L4 op into the ledger + L0 cache). Covered by the test `resolve_local_falls_through_then_external_l4_caches_for_l0`. **Engine parity is now COMPLETE** (commits `d7d73c2`, `7261e29`): the engine does all the portable governance — tier **gating** (`authorize_tier`: PreTool hooks + session grant/ceiling + JCP cascade gate, incl. the L4 no-phone-home gate), **PostTool hooks** and **steering** in `note_close` — and emits `Outcome.edit` (`AppliedEdit { op, path, old_text, new_text }`) so a GUI host can layer the genuinely `cx`-bound steps (methodology citation/export, sandbox attestation, the review surface, session-manager op accounting) on top of a local close. Builders: `with_gate` / `with_hooks` / `with_sessions` / `with_steering`. 5 engine tests. **The remaining work is the panel rewrite — and it is panel-WIDE, not localized.** The panel's cascade state (`tier_closes`, `ledger_*`, `energy_counter`, `session_key`, `l0_cache`, `l2_index`, `l3_model`, `codebase_index`, `cascade_gate`, `session_mgr`, `run_steering`, `local_energy_uj`, `last_intent`, `pending_prior`) is read across the whole crate — the `state()` snapshot, the render methods, the methodology/release/ship surfaces, the steering control, and the daemon control-dispatch. Migrating to `self.cascade: CascadeEngine` means rewiring **every** reader to `self.cascade.()` (add accessors as needed), not just `submit_prompt`. Do it incrementally with the editor running: 1. Add `cascade: CascadeEngine` (built in `new` from the panel's gate/hooks/ sessions/steering/energy/key/model) **alongside** the existing fields. 2. Route `submit_prompt` through `cascade.resolve_local` on a background thread over a `StringDocument` snapshot; on a local close, write the buffer back and layer methodology/attest/review on `Outcome.edit`; on fall-through, the existing `send_to_l4`, recording via `cascade.record_external_close`. Verify in the editor (checklist below) before deleting anything. 3. Only then, one subsystem at a time, repoint the other readers (`state()`, render, methodology, release, steering, daemon control) at `self.cascade`, deleting each now-duplicated panel field/method as its last reader moves. Re-verify in the editor after each. ## Target shape The panel holds **one** `CascadeEngine` instead of its own cascade state: - Delete the panel fields the engine already owns: `l0_cache`, `l2_index`, `l3_model`, `codebase_index`, `energy_counter`, `energy_baseline`, `session_key` (move into the engine), and the ledger fields (`ledger_head`, `ledger_seq`, `ledger_ops`, `ledger_energy_uj`, `ledger_intact`, `ledger_loaded`, `l2_loaded`), `tier_closes`, `local_energy_uj`, `last_intent`, `pending_prior`. - Add `cascade: CascadeEngine` (constructed in `new` with the worktree root, the session key, and `host_energy_counter()`; set `l3_model` after load). Delete these panel methods (now in the engine): `attempt_l1_deterministic`, `attempt_l2_then_l3`, `attempt_l3_then_l4`, `record_tier_edit`, `record_l3_edit`, `bracket_energy`, `note_close`, `append_to_ledger`, `ensure_ledger_loaded`, `seal_op_receipt`, `ledger_receipt`, `verify_entry_seal`, `energy_uj_now`, and the L0/L1/L2 portion of `submit_prompt`. Keep `host_energy` (session aggregate for the status line) and the L4/ACP machinery (see below). ## STATUS: migration DONE + verified; dead-code cleanup remains The panel now fully delegates to `joule_core::cascade::CascadeEngine`: `submit_prompt` runs L0–L3 on a background thread over a buffer snapshot and the L4 `Stopped` handler records via `record_external_close` — one engine, one ledger chain. Verified by `cascade_submit_drives_engine_and_closes_at_l1` (a `#[gpui::test]` driving `control_dispatch("submit")`) and all 24 panel tests. Commits: `agent_joule_panel: Route submit_prompt through the shared cascade engine` and `… Route the L4 close through the engine ledger`. **Residual (≈11 dead-code warnings, no errors): the superseded inline-tier code.** It is an interconnected, partly test-entangled cluster — remove it as one careful pass (NOT with a naive brace-counting script: this file has `{`/`}` inside string literals like `"fn main() {"` and `'{'` char literals plus `'a` lifetimes, which fooled an auto-strip into deleting 835 lines. Use rust-analyzer's remove-unused, or delete by verified line ranges with a Rust-aware lexer, then re-run the panel tests). Cluster to delete together: - Methods: `attempt_l1_deterministic`, `attempt_l2_then_l3`, `attempt_l3_then_l4`, `ensure_ledger_loaded`, `ensure_l2_loaded`, `verify_entry_seal`. - Free fns: `harness_prompt`, `parse_edit`, `format_symbol_context`, `symbol_kind_label`, `repo_relative`, `format_codebase_context`, `l2_threshold`; const `L2_THRESHOLD`. - Fields (+ their `new()` inits): `ledger_loaded`, `l2_loaded` (only read by the dead `ensure_*`); the `l2_index` / `L2Entry` chain (written by `index_for_l2`, which the test-only `record_tier_edit` calls — drop that call, then remove `index_for_l2` + `l2_index` + `L2Entry`). - Tests that reference the above (delete with the code they cover; the engine has equivalent coverage): `l3_probe_simple_vs_harness`, `harness_probe_one`, `embeds_separate_paraphrase_from_unrelated`, and the `L2_THRESHOLD` import in the test `use super::{…}` block. - Then `record_tier_edit` / `record_l3_edit` / `record_multi_edit` / `note_close` / `append_to_ledger` / `seal_op_receipt` / `ledger_receipt` / `bracket_energy` / `energy_uj_now` / `l0_cache` / `ledger_*` become candidates too if their only remaining callers are the panel's own cascade tests — migrate those tests to `joule_core::cascade` (which already tests L0/L1/ledger) and delete the panel copies for the full dedup. Known minor behavior delta to revisit during cleanup: the engine's L3 prompt uses the `@mention` context but not LSP symbols or codebase-retrieval augmentation (the panel's old L3 did); and L2 retrieval is in-session only (the engine does not yet persist/rehydrate `l2_index.jsonl` across restarts as the panel's `ensure_l2_loaded` did). Add these to the engine if parity matters. ## Gating: use the per-request snapshot (NOT engine-held governance) A GUI host's gating reads **live, shared, mutable** state — `session_mgr` energy, the `hooks` registry, `cascade_gate` — that other panel features mutate, and that is not `Send`. So the engine must NOT hold it to gate on a background thread (stale gating = a security bug). Instead: the panel keeps `authorize_tier` (reading its live state + `self.cascade.life_spent_uj()`), computes `allow = [authorize_tier(L0)..authorize_tier(L4)]` on the **foreground** before spawning, and calls `cascade.set_request_authorization(Some(allow))`. The engine honors that snapshot (commit adding `set_request_authorization`). Same pattern for the other governance side-effects (PostTool hooks, steering, methodology, attestation, session-mgr op recording): the engine does the portable half (tier_closes/energy/ledger/cache, and its own empty hooks/steering no-op for the GUI); the panel fires its live-state half on the foreground after the close, driven by `Outcome.edit` (the op + path + old/new text). ## No safe compile-only milestone — do this live at the editor There is no additive first step: a lazily-built `cascade` field is dead until `submit_prompt` routes through it, and the instant it does, the panel's own `l0_cache`/`ledger_*`/`tier_closes`/`local_energy_uj` go stale and every `state()`/render reader is wrong until repointed at `self.cascade.()`. It is one coordinated change whose correctness — gating freshness, no double-fired hooks, no UI freeze on L3, no missed `cx.notify()` — is a **runtime** property `cargo check` cannot see. Execute it with the editor running, verifying after each step (route `submit_prompt` first; then repoint `state()`/render/methodology/ release/steering/daemon-control one subsystem at a time, deleting each duplicated panel field/method as its last reader moves). ## The two seams 1. **`Document` over the editor buffer** — the trait is `cx`-free but the buffer needs `cx`. Bridge with a snapshot: read `active_singleton_buffer` text + path via `cx` *before* calling the engine, wrap in an in-memory `StringDocument` (add this impl to `joule_core::document`), call the engine, then write `document.text()` back to the buffer via `cx` *after*. (`FsDocument` already proves the trait; `StringDocument` is its in-memory sibling.) 2. **`L4Dispatch` over `AcpThread`** — the `AcpThread` is GPUI/non-`Send`, so it cannot run on a background thread. Therefore the engine runs **L0–L3 on a background thread** over the snapshot document, and L4 is **not** dispatched from the engine in the GUI. Instead: if `resolve` returns "no local tier closed" (tier `None`), the panel falls back to its **existing** async `send_to_l4` on the foreground (the current `AcpThread.send` + the `AcpThreadEvent::Stopped` subscription that builds the L4 op). The L4 op is then handed to the engine for ledger/cache recording via a small `CascadeEngine::record_external_close(op, tier, energy_uj)` method (add it — it is `append_to_ledger` + `l0_cache.insert` + `note_close`, already private). Net: the engine owns L0–L3 and all recording; the GUI keeps only the GPUI-bound L4 transport, calling back into the engine to record. ## Async flow for `submit_prompt` ``` read prompt + buffer snapshot (cx) // foreground, instant spawn background: outcome = engine.resolve_local(req, &mut snapshot_doc) // L0..L3, no L4 foreground (on completion): if outcome closed: write snapshot_doc back to buffer (cx); status; notify else: send_to_l4(...) // existing path; records via engine ``` Add `CascadeEngine::resolve_local` = `resolve` with the L4 branch replaced by "return `tier: None`" (so the engine never needs an `L4Dispatch` in the GUI). The existing `resolve(.., l4)` stays for the headless worker. `engine.resolve_local` must be callable on a background thread: confirm `CascadeEngine: Send` (its fields — `L0Cache` Mutex, `Arc`, `Box`, `SigningKey`, plain data — are `Send`; the snapshot `StringDocument` is `Send`). The engine is owned by the panel, so either move it into the background closure and back, or wrap it `Arc>` and lock for the resolve (one request at a time). ## Verification checklist (editor running) - [ ] `cmd-alt-1` → connect agent; prompt a deterministic edit ("rename X to Y") → closes at L1, buffer updates, status shows `⚡ closed at L1`. - [ ] Repeat the identical prompt on the same buffer state → L0 cache hit. - [ ] A retrieval paraphrase over the same buffer → L2 replay. - [ ] A novel prompt with a model loaded → L3 generation, **no UI freeze** during generation (it runs on the background thread). - [ ] A novel prompt with no local close → L4 ACP round-trip renders, op recorded (ledger grows, energy/status line correct). - [ ] `.joule/ledger.jsonl` still hash-chains + seals verify across a restart. ## Risk register - UI freeze if any tier accidentally runs on the foreground (L3 is the danger). - Borrow-panic from updating the buffer entity while already in an update. - Missed `cx.notify()` after the background close → stale UI. - The L4 handoff: ensure exactly one of (engine L0–L3) or (panel L4) records the op — no double-record, no missed record.