# Session handoff — 2026-05-19 (20)

Twentieth handover. **Interim** handoff: this session
finished ADR-0027 (§1), fixed two manual-testing bugs (§1),
and **started ADR-0028** — query plans / `explain`. ADR-0028
**step 1 of 5 is done and committed**; steps 2–5 are planned
in full detail (§3) and not yet built. A fresh session can
implement them from this file + the ADR without re-exploring.

## State at handoff

**Branch:** `main`. Working tree clean. **12 commits** since
handoff-18 (`39b92a7`), all local — push asynchronously, not
blocking.

```
<this file>  docs: handoff 20 — ADR-0028 step 1 done, 2-5 planned
03d8a09 ui: styled-output-line mechanism (ADR-0028 step 1)
a1e4932 docs: handoff 19 update — both manual-testing bugs fixed
3a40ae2 runtime: don't record an unmodified temp as the --resume target
f239ca5 walker: keep optional trailing flags completable after `--`
0e5f226 docs: handoff 19 — ADR-0027 highlight/hint wiring finished
c1c9f6c runtime: extract the indicator debounce into a tested state machine
400fb71 ui: surface diagnostics in the ambient hint panel (ADR-0027 §2)
bbfb70c ui: overlay diagnostic spans on the input field (ADR-0027 §2)
437b2f2 walker: flag LIKE on a numeric column (ADR-0027 Amendment 1)
3912fb5 walker: precise per-literal spans for expression WARNINGs
426e801 command: Operand carries a source span
```

**Tests:** **1133 passing, 0 failing, 1 ignored** (`cargo
test`). The ignored test is the long-standing `` ```ignore ``
doc-test in `src/friendly/mod.rs`. Typing-surface matrix:
**161 cells**, unchanged.

**Clippy:** clean (`cargo clippy --all-targets -- -D
warnings`, nursery group).

## §1. Done before ADR-0028 — see handoff-19

This session first **finished ADR-0027** (the §2 highlight /
hint wiring + precise WARNING spans + the `LIKE`-on-numeric
Amendment 1 + the debounce state machine) and **fixed two
manual-testing bugs** (optional trailing-flag completion after
`--`; the `--resume` unpersisted-temp pointer). All of that is
documented in **handoff-19** — read it for that context. This
file is about ADR-0028.

## §2. ADR-0028 step 1 — done (`03d8a09`)

ADR-0028's build-order step 1: the **styled-output-line
mechanism**. `OutputLine` gained an optional
`styled_runs: Option<Vec<OutputSpan>>` payload;
`render_output_line` (`src/ui.rs`) gained a branch that, when
the payload is present, renders the line span-by-span — each
`OutputSpan { byte_range, class }`'s semantic
`OutputStyleClass` (`Neutral` / `Efficient` / `Expensive` /
`AutomaticIndex`) resolved to a theme colour by
`output_span_style`. The echo path is untouched; a line with
no payload still renders whole-line by `kind`.

`theme.plan_efficient` was added — a green distinct from
`theme.system` (ADR-0028 §6); `theme.warning` is reused for
"expensive". `OutputLine::styled(...)` is the constructor for
payload-bearing lines.

No user-visible change yet — nothing produces styled lines.
Two `ui` tests cover the new branch and the no-payload
fallback.

## §3. ADR-0028 steps 2–5 — the plan

Read `docs/adr/0028-query-plans.md` first — it is the spec.
Below is the validated build plan with file:line anchors, so
no re-exploration is needed.

### Three gotchas found during exploration

1. **`Subgrammar` needs `&'static Node`; the query shapes are
   `const`.** `SHOW_DATA` / `UPDATE_SHAPE` / `DELETE_SHAPE`
   (`src/dsl/grammar/data.rs:68,300,311`) are `const`. A
   `const` cannot be referenced as `&'static`; and promoting
   them to `static` breaks `const SHOW_CHOICES`
   (`data.rs:76`), which embeds `SHOW_DATA` *by value*.
   **Fix:** add three thin wrapper `static`s over the existing
   `_NODES` slices —
   `static EXPLAIN_SHOW_DATA: Node = Node::Seq(SHOW_DATA_NODES);`
   etc. — and have `explain` reference *those*. A `static` may
   read a `const`, and a `const` array may take `&` of a
   `static`. No churn to the existing shapes.
2. **`build_show` is positional.** `build_show`
   (`data.rs:347`) reads `words.nth(1)` to tell `data` from
   `table` — index 1 assumes `show` is the entry word at
   index 0. Under `explain` the indices shift. **Fix:**
   extract a role-based `build_show_data(path)` helper (it
   only needs `require_ident(path,"table_name")`,
   `build_show_filter`, `build_show_limit` — all already
   position-independent); `build_show`'s `data` arm calls it.
   `build_update` / `build_delete` are already role-based, so
   they need nothing.
3. **Steps 2 and 3 must land in one commit.** Adding the
   `Command::Explain` enum variant breaks every exhaustive
   `match Command` — notably `execute_command_typed`
   (`src/runtime.rs:1685`). So the grammar/AST (ADR step 2)
   and the worker path (ADR step 3) cannot be separate green
   commits. Combine them: one commit landing `explain`
   end-to-end with a **plain** tree render; step 4 then adds
   the styled / annotated tree.

### Step 2 + 3 (one commit) — `explain` end to end

**Grammar — `src/dsl/grammar/data.rs`:**
- Add `static EXPLAIN_SHOW_DATA / EXPLAIN_UPDATE /
  EXPLAIN_DELETE = Node::Seq(SHOW_DATA_NODES / UPDATE_NODES /
  DELETE_NODES)`.
- `const EXPLAIN_SHOW_NODES = &[Word("show"),
  Subgrammar(&EXPLAIN_SHOW_DATA)]`; likewise update / delete.
  `const EXPLAIN_CHOICES = &[Seq(EXPLAIN_SHOW_NODES), …]`;
  `const EXPLAIN_SHAPE = Node::Choice(EXPLAIN_CHOICES)`. The
  surface is `explain show data …` / `explain update …` /
  `explain delete from …` (ADR §1 — the inner entry word is
  part of the surface).
- Extract `build_show_data(path)`; have `build_show`'s
  `Some("data")` arm call it.
- `build_explain(path)`: the words are `[explain, show|update|
  delete, …]`; `words.nth(1)` discriminates → call
  `build_show_data` / `build_update` / `build_delete`, box the
  result: `Command::Explain { query: Box::new(inner) }`.
- `pub static EXPLAIN: CommandNode { entry:
  Word::keyword("explain"), shape: EXPLAIN_SHAPE, ast_builder:
  build_explain, help_id: None, usage_ids:
  &["parse.usage.explain"] }`.

**Registry:** add `&data::EXPLAIN` to `REGISTRY`
(`src/dsl/grammar/mod.rs:466`). `"explain"` is a free entry
word.

**Catalog:** add `parse.usage.explain` to
`src/friendly/strings/en-US.yaml` (the `parse: usage:`
section) **and** `("parse.usage.explain", &[])` to
`src/friendly/keys.rs` `KEYS_AND_PLACEHOLDERS` (sorted) — the
`keys_validate_against_catalog` test enforces this.

**Command AST — `src/dsl/command.rs`:**
- `Command::Explain { query: Box<Command> }`.
- `verb()` (`:442`, exhaustive) — add `Self::Explain { .. } =>
  "explain"`.
- `target_table()` (`:481`, exhaustive) — add `Self::Explain
  { query } => query.target_table()`.
- `display_subject()` (`:530`) has a `_` arm — no change
  needed.

**DB worker — `src/db.rs`:**
- New `ExplainRow { id: i64, parent: i64, detail: String }`
  and `QueryPlan { display_sql: String, rows: Vec<ExplainRow> }`.
- `Request::ExplainPlan { query: Command, reply:
  oneshot::Sender<Result<QueryPlan, DbError>> }` (`Request` at
  `:409`); dispatch arm in `handle_request`.
- `Database::explain_query_plan(query: Command) ->
  Result<QueryPlan, DbError>` async method (mirror the
  existing `oneshot` request methods).
- **Separate SQL construction from execution.** Extract
  `build_query_data_sql` / `build_update_sql` /
  `build_delete_sql` returning `(String, Vec<rusqlite Value>)`
  from `do_query_data` (`:4730`) / `do_update` (`:4539`) /
  `do_delete` (`:4642`) — the SQL string + params are already
  fully assembled before the first `conn.prepare` /
  `execute_with_fk_enrichment` in each, so the cut is clean.
- `do_explain_plan(conn, query)` — match the inner command,
  call the shared SQL builder, run `EXPLAIN QUERY PLAN <sql>`
  with the params bound (template: `read_table_indexes`,
  `:3305` — the same multi-row read shape), collect
  `ExplainRow`s. Also build the **display SQL** (ADR §3):
  standard SQL with double-quoted idents (`quote_ident`,
  `:1713` — make it `pub(crate)` or duplicate), WHERE literals
  *inline* (a `compile_operand` variant that renders the
  literal instead of pushing a `?` param), `<>` for
  inequality (`compare_op_sql` already emits `<>`), and the
  implicit `ORDER BY <pk>` that `limit` adds.

**Result flow:**
- `CommandOutcome::QueryPlan(QueryPlan)`
  (`src/runtime.rs:1490`).
- `AppEvent::DslExplainSucceeded { command: Command, plan:
  QueryPlan }` (`src/event.rs:17`).
- `execute_command_typed` (`runtime.rs:1685`) arm:
  `Command::Explain { query } =>
  database.explain_query_plan(*query).await
  .map(CommandOutcome::QueryPlan)`.
- `spawn_dsl_dispatch` (`runtime.rs:1129`) — map
  `CommandOutcome::QueryPlan` → `AppEvent::DslExplainSucceeded`.
- `App::update` — handle `DslExplainSucceeded`: for this
  commit render a **plain** tree (indented `detail` strings +
  the display SQL line), pushed as `OutputKind::System`
  lines. Step 4 swaps in the styled renderer.

### Step 4 (one commit) — `render_explain_plan` + taxonomy

- `render_explain_plan` in `src/output_render.rs` (alongside
  `render_structure` / `render_data_table`). Builds the
  box-drawing tree from `id` / `parent`; node text is the
  `detail` string **verbatim**; the display SQL is shown above
  the tree; the command echo precedes it.
- Annotation taxonomy (ADR §4): a small substring-pattern
  table classifying each `detail` into Full scan / Index
  search / Covering index / PK lookup / Automatic index /
  Temp B-tree / Neutral. Unmatched → Neutral.
- The renderer emits `OutputLine`s with `styled_runs:
  Some(...)` — only the category-bearing keywords of each
  `detail` get an `OutputStyleClass` (Efficient → green,
  Expensive → amber, AutomaticIndex → amber + bold + a
  distinct marker per ADR §6); connectors and names stay
  Neutral. The mechanism for this is **already built**
  (step 1).
- `App::update`'s `DslExplainSucceeded` handler switches from
  the plain render to `render_explain_plan`.

### Step 5 (one commit) — matrix + verification

- Typing-surface matrix cells for `explain` in
  `tests/typing_surface/` (parse / completion / hint of
  `explain show data …` etc.).
- Full suite green vs. the 1133 baseline; clippy clean;
  matrix-snapshot discipline.
- Final handoff (handoff-21).

## §4. What's next

1. **ADR-0028 steps 2–5** per §3 — `explain` is the only
   active feature in flight.
2. After ADR-0028: the handoff-16 design trio is then fully
   implemented. Other open clusters unchanged from
   handoff-16/17/18/19 (snapshot/undo `U`-series; constraints
   `C3`; `C4` m:n; `C3a`; `C1`; `H1`; `SD1`; `TT5` CI; `V4`;
   `I1`; `TU1`). Prioritisation is a user decision — ask.

## §5. How to take over

1. **Read this file, then `docs/adr/0028-query-plans.md`**
   (the spec), then **handoff-19** (ADR-0027 + the two bugs).
2. **Read `CLAUDE.md`** — working-style rules.
3. **Run `cargo test`** — 1133 passing, 0 failing, 1 ignored.
4. **Run `cargo clippy --all-targets -- -D warnings`** — clean.
5. **Implement ADR-0028 §3** — step 2+3 as one commit, then
   step 4, then step 5. The file:line anchors in §3 are from
   this session's exploration; the three gotchas are the
   non-obvious parts.

### Note on the typing-surface matrix

`tests/typing_surface/` is **161 cells**. After the `explain`
grammar lands (step 2+3), add cells for it (step 5) and apply
the matrix-snapshot discipline from handoff-17/18/19: a
failing cell with *correct* new behaviour → update its
snapshot; with *wrong* behaviour → the cell earned its keep.
