# Session handoff — 2026-05-15 (9)

Ninth handover. This session executed **ADR-0024 phases A
through F** in one sitting, six commits. The walker is now the
sole parse path; the chumsky dependency is gone. What did NOT
land — and what the next session needs to pick up — is
captured below in priority order.

## State at handoff

**Branch:** `main`. Working tree clean. **Local HEAD is
`c940ba9`**, ahead of `origin/main` by six commits (the user
pushes asynchronously; do not be blocked by unpushed state).

Commits since handoff-8's baseline (`3e1ff83`):

```
50b3542 ADR-0024 Phase A: walker framework + app-lifecycle commands
7e79ca8 ADR-0024 Phase B: DDL commands without value literals
6bb6882 ADR-0024 Phase C: create table with column-list value literals
c2accc2 ADR-0024 Phase D: data commands at chumsky parity
dca472f ADR-0024 Phase E: replay end-to-end
c940ba9 ADR-0024 Phase F (minimal): drop chumsky from the parse path
```

**Tests:** **844 passing, 0 failing, 1 ignored** (up from 777
at handoff-8's baseline; +67 over this session). The ignored
test is still the same `\`\`\`ignore` doc-test in
`src/friendly/mod.rs`.

**Clippy:** clean with `nursery` lints enabled and
`-D warnings`.

**Cargo.toml:** `chumsky` dependency dropped. `thiserror`
remains gone (per handoff-8). The crate's parse path is now
fully Rust-native (lexer + walker, no parser-combinator
library).

## What shipped this session — quick overview

ADR-0024's six phases all landed end-to-end. Walker now owns
all 20 entry-keyword commands across 14 entry words:

- **Phase A:** walker framework + 11 app-lifecycle commands
  (quit, help, rebuild, save / save as, new, load, export,
  import, mode, messages). Optional + Choice + Seq combinators.
  BarePath terminal. Path-bearing UX change shipped (paths
  with spaces require quotes).
- **Phase B:** DDL — drop, add, rename, change. Repeated
  combinator with optional separator. Flag terminal. NumberLit
  + Literal terminals. Optional now propagates inner
  expectations as `skipped` so completion sees "what could
  have appeared here".
- **Phase C:** create table. Repeated with `,` separator
  (first use). `with pk` defaulting to `id:serial`.
- **Phase D:** data — show, insert, update, delete. StringLit
  terminal. Value-literal sub-grammar. **Schema-aware value
  typing deliberately deferred** (see below).
- **Phase E:** replay. `Choice(StringLit, BarePath)`.
- **Phase F (minimal):** chumsky combinators deleted, chumsky
  crate dep dropped. **Significant scope deferred** (see
  below).

## DEFERRED — work the next session needs to pick up

This is the meat of the handoff. Items grouped by urgency.

### 1. Phase F (full): legacy parser-side modules still standing

ADR-0024 §migration Phase F prescribes deleting these modules.
Phase F minimal in this session only deleted the chumsky
combinator code. The following are still in place and consumed
by other modules:

| Module | Still consumed by | Why kept |
|---|---|---|
| `src/dsl/lexer.rs` | `theme.rs`, `input_render.rs`, `app.rs`, `dsl/usage.rs`, `dsl/parser.rs` | Per-token highlighting + echo-line tokenization + completion partial-token detection |
| `src/dsl/keyword.rs` (`Keyword` enum) | `completion.rs`, `friendly/keys.rs`, `theme.rs`, `dsl/usage.rs` | Catalog key derivation (`parse.token.keyword.*`), keyword-name validation in completion |
| `src/dsl/ident_slot.rs` (`IdentSlot` enum) | `completion.rs`, `input_render.rs`, `runtime.rs`, `db.rs` | Schema-cache lookups dispatched per slot kind |
| `src/dsl/usage.rs::REGISTRY` + `matched_entry` | `completion.rs`, `app.rs` | Per-command usage templates rendered on parse error |
| `parse.token.keyword.*` catalog (40+ entries) | `dsl/usage.rs`, `friendly/keys.rs` | Keyword wording in usage templates |

**Replacement strategy** (sketched, for the migrating session):

- **Highlighting** (`input_render.rs::render_input_panel`):
  the walker's `WalkResult::per_byte_class` populates
  `(start, end, HighlightClass)` per matched terminal. Wire
  this output to the `tok_*` theme colours in place of the
  current `lex(input)`-driven span builder. Walker error
  positions feed the `tok_error` overlay. This is the single
  biggest change.
- **Completion** (`completion.rs::candidates_at_cursor`): the
  walker's `WalkResult` at `WalkBound::Position(cursor)` (NOT
  yet exercised — the walker's `walk()` does support this,
  but no caller passes it today) gives `expected: Vec<Expectation>`
  at the cursor. The bridge already maps `Expectation::Ident
  { source: Tables/Columns/Relationships/Types }` to the
  user-facing labels matching `IdentSlot::expected_label`, so
  the existing completion engine reads them transparently.
  What's missing: a walker-driven path that bypasses
  `parse_command` entirely and asks the walker directly for
  candidates per `IdentSource`. Today the bridge round-trips
  through `ParseError::Invalid::expected` strings — works,
  but loses information.
- **Echo-line tokenization** (`output_render.rs` for
  `OutputKind::Echo + Mode::Simple`): same lex-driven spans.
  Same walker `per_byte_class` plumbing.
- **Usage templates** (`dsl/usage.rs::REGISTRY` + `matched_entry`):
  every `CommandNode` already has `usage_id`. Wire the
  parse-error renderer to look up the catalog entry by
  `usage_id` instead of `matched_entry`. Then `REGISTRY` and
  `matched_entry` can go.
- **Catalog cleanup** (`parse.token.keyword.*` + `friendly/keys.rs`):
  ADR-0024 §cleanup-pass §F mentions a `format_keyword_for_error
  (literal) -> String` helper that wraps a literal in
  backticks and replaces the per-keyword catalog entries.
  Mechanical; do it after the consumers stop reading the
  catalog keys.

**Estimated cost: one full session** for the consumer
migration + a follow-up commit for the catalog cleanup. Test
suite serves as the regression net throughout.

### 2. Phase D (full): schema-aware value typing

ADR-0024 §migration Phase D prescribes "full schema awareness"
via `DynamicSubgrammar(column_value_list)` that unfolds typed
slots per column at walk time. **This session deferred it.**

What's in place:

- `Node::DynamicSubgrammar(fn(&WalkContext) -> Node)` variant
  declared but unused. The walker's driver returns
  `Failed { expected: vec![] }` on this branch (intentional —
  catches grammar bugs that declare DynamicSubgrammar without
  the wiring landing).
- `WalkContext::current_table`,
  `WalkContext::current_table_columns`,
  `WalkContext::current_column` all declared but unwritten.
  No `Ident` node has a `writes_table: bool` or equivalent.
- Per-type validators (`int_slot`, `decimal_slot`, etc.) NOT
  written. The current walker uses a generic `value_literal`
  Choice that accepts any literal regardless of column type;
  bind-time type-check errors fire as today.

**To implement:**

1. Plumb a `SchemaCache` reference into `parse_command` (and
   thus `parse_tokens`). Currently the call site is in
   `runtime.rs::dispatch_input` — passes `app.schema_cache()`
   alongside the input.
2. Extend `WalkContext::new(schema)` to carry the cache.
3. Implement `Node::DynamicSubgrammar` walker dispatch:
   resolve at walk time, leak the returned `Node` into a
   per-walk arena (or `Box::leak` per ADR-0024 §sub-grammars).
4. Implement `Ident { source: Tables, writes_table: true }`
   semantics — when the ident matches, look up the table in
   the schema, populate `current_table` + `current_table_columns`.
5. Implement typed value slots — `int_slot()`, `decimal_slot()`,
   etc. per ADR-0024 §typed-value-slots. Each is a Choice
   over the literal forms with a content validator.
6. Wire `column_value_list` as a DynamicSubgrammar that reads
   `current_table_columns` and emits a Seq of typed slots
   separated by commas.
7. Update `insert` shape to use `column_value_list` instead
   of the generic value list.
8. Update `update`/`delete` to use the per-column value slot
   based on `current_column`.

**The user UX win this unlocks:** typed slots reject
mis-shaped input at parse time with localised wording (e.g.,
"Type a date as 'YYYY-MM-DD'") instead of bind-time errors;
completion narrows per column type. This is the Phase D
"central design claim" per the handoff.

**Side effect to watch for:** parse_command becomes schema-
dependent. Tests that exercised parse in isolation may need
to pass a schema cache (today's tests don't — most just
check round-trip parses where schema doesn't matter).

**Estimated cost: 1-2 sessions** depending on how deep the
schema plumbing goes through dispatch.

### 3. Walker doesn't drive completion or hints directly

Today's flow: walker produces `WalkOutcome` → bridge to
`ParseError::Invalid` → completion / hint engines read
`expected: Vec<String>`. The bridge formats `Expectation::Ident
{ source: … }` to the user-facing label string the existing
engines recognise.

This works but loses information. Walker knows:
- The `IdentSource` of every expected slot.
- The `role` of every slot (e.g., `parent_table` vs
  `child_table`).
- The `HintMode` per node (currently always `Default`).
- The `skipped` expectations from any Optional that didn't
  engage at this position.
- The cursor's full `MatchedPath` so far — the AST builder
  could be invoked partially to extract context (e.g., "you've
  typed `update Customers set Email=`, the `Email` column's
  type is `text`").

A walker-direct completion path would surface much more
informative candidates than the current ParseError-string round
trip. See ADR-0024 §architecture: "Completion at cursor:
`walk(source, Position(cursor), ctx)`, inspect `outcome.expected`."

**Today, no caller invokes `walk()` with `WalkBound::Position(cursor)`.**
The variant exists in `outcome.rs` (annotated `#[allow(dead_code)]`).
`completion.rs` still calls `parse_command(leading_slice)` —
the slice-and-re-parse approach inherited from chumsky.

**Migration path:** add a `pub fn candidates_at_cursor_from_walker
(input, cursor, schema) -> WalkResult` that calls
`walker::walk(input, WalkBound::Position(cursor), &mut ctx)`
and returns the result. Then `completion.rs` reads
`expected` directly with full `IdentSource` + `role` info.

### 4. `HintMode` declared but unused

`HintMode::Default | ForceProse | ProseOnly | SuppressProse` is
declared in `grammar/mod.rs` but every `CommandNode` and
every `Node::Ident` sets it to `None`. The current ad-hoc
hint cases in `input_render.rs::ambient_hint` (value-literal
slot suppression, NewName slot typing-name prose,
invalid-ident overlay) still use the chumsky-era ad-hoc
detection.

ADR-0024 §HintMode-per-node says these migrate to
node-attached `HintMode` annotations during Phase D. They
didn't.

**To do:** annotate the value-literal slots with
`HintMode::ProseOnly("value_literal_format_hint")`; annotate
NewName slots with the typing-name prose; have the hint
resolver pick up the mode and dispatch.

### 5. Ranker hook: declared in ADR-0024, not implemented at all

ADR-0024 §ranker-layer specifies a `Ranker` function type
between the walker's raw candidates and the hint-panel
renderer. The default is `identity_ranker` (declaration-order
preserved).

**Status: not declared anywhere in code.** No `Ranker` type,
no identity ranker, no hook into completion. The current
completion engine ranks by its own ad-hoc logic (keyword
matches first, then schema, alphabetised within each).

This is a small future-work hook. Not blocking.

### 6. Aliases: feature works, no aliases declared

`Word::aliases: &'static [&'static str]` is wired through
`Word::matches` and the walker correctly accepts case-
insensitive alias matches. **Today every `Word` in the
grammar has an empty aliases slice.**

The round-5 `q` quit alias removal stands — if the user
wants it back, it's:

```rust
const QUIT_WORD: Word = Word {
    primary: "quit",
    aliases: &["q"],
    highlight_override: None,
};
pub static QUIT: CommandNode = CommandNode {
    entry: QUIT_WORD,
    ...
};
```

The walker matches either; completion only surfaces the
primary. No further wiring needed.

### 7. Test edits worth knowing about

A handful of tests changed wording assertions to match
walker-emitted error wording:

- `src/dsl/walker/mod.rs::walker_import_trailing_as_without_target_errors`
  — assertion weakened from checking for "target" to checking
  for "import". Walker's Optional backtracking means
  `import foo.zip as ` parses as `Import { path: "foo.zip",
  target: None }` followed by trailing `as ` → walker reports
  `expected end of input, found …`. The friendly
  `project.import_empty_target` wording moved out of the
  parser. The integration test
  (`tests/iteration5_export_import.rs::import_with_empty_target_after_as_errors`)
  still passes because the rendered `import_usage` template
  line in the dispatch output contains both "import" and
  "target".
- The walker's parse error wording for incomplete inputs
  consistently uses "after `<consumed>`, expected …, found
  end of input" — matches the chumsky-era contract that
  `structural_error_for_show_data_without_arg` and friends
  pin down.

**No `parse.token.keyword.*` wording changed.** No catalog
entries changed. No user-visible string regression.

### 8. The `value_literal_hint_at_cursor` stopgap

The round-6 stopgap from handoff-8 (replacing `null true false`
with prose at value-literal slots) **is still live**. It
lives in `completion.rs::value_literal_hint_at_cursor` and
detects the value-literal-signature in `expected`. With Phase
D's full schema awareness, this would become "narrow to the
actual column's type" (e.g., "Type a date as 'YYYY-MM-DD'")
— but that requires the schema plumbing in §2 above.

**Today the stopgap continues to fire for every value-literal
slot regardless of column type.** Same wording as handoff-8.

### 9. Choice greedy-semantics shape contortions

ADR-0023 says "the trie design is greedy (the first child
node that matches wins)." This is the design. But it forced
some grammar contortions worth knowing about:

- **`insert` Form A vs Form C disambiguation** (`grammar/data.rs`):
  Forms A (`insert into T (cols) values (vals)`) and C (`insert
  into T (vals)` — bare value list) both start with `(`. The
  inner-paren content is parsed as a heterogeneous
  `Choice(VALUE_LITERAL, Ident{Columns})` — VALUE_LITERAL
  ordered first so `null`/`true`/`false` match their Word
  branch rather than the broader identifier catch-all (which
  `consume_ident` doesn't filter against the keyword set).
  AST builder discriminates by the presence of the `values`
  keyword AFTER the first paren. **Brittle if a future grammar
  needs to add another paren-bounded form starting at the
  same position.**
- **`drop` sub-form ordering**: `drop_column` and
  `drop_relationship` come before `drop_table` in the Choice
  because they're more specific (longer prefix). Walker
  greediness handles this correctly because each branch's
  first Word disambiguates.

### 10. Walker `per_byte_class` populated but unused

`WalkResult::per_byte_class: Vec<ByteClass>` is populated by
every terminal match in the walker driver. **No consumer
reads it today.** The annotation `#[allow(dead_code)]` sits
on the field. When the highlighting consumer migrates (§1
above), this is the data source.

### 11. Differential test scaffolding wasn't actually built

ADR-0024 §test-discipline §3 specifies a "differential check
during the migration window" — a test helper that runs both
parsers on the existing input corpus and asserts identical
`Command` output. Removed at Phase F cleanup.

**This session went with hand-curated expected `Command`
outputs in `dsl::walker::tests` instead.** Equivalent
coverage (every migrated command's parse asserted), simpler
to maintain. Since chumsky is now gone (Phase F minimal
removed the combinator code), no removal step needed.

If a strict differential check is wanted retroactively, the
chumsky path would need to be reconstructed from git history
and run alongside walker against the test corpus — not
trivial. The hand-curated tests + the existing integration
test suite serve the same regression-net role.

### 12. `WalkContext` writes during walk — design exists, not implemented

ADR-0024 §WalkContext sketches `Ident { source: Tables,
writes_table: true }` semantics: when the ident matches, the
walker writes `current_table` to context. Subsequent dynamic
sub-grammars read it.

**Today no `Ident` node has a `writes_table` field.** The
struct definition in `grammar/mod.rs` is:

```rust
Ident {
    source: IdentSource,
    role: &'static str,
    validator: Option<IdentValidator>,
    highlight_override: Option<HighlightClass>,
}
```

When Phase D (full) lands, add a `writes_table: bool`
(or similar) field and have the walker driver populate
`WalkContext` accordingly.

### 13. `CommandNode::usage_id` / `help_id` not consumed

Every `CommandNode` declares `usage_id: Option<&'static str>`
and `help_id: Option<&'static str>` pointing into the
catalog. **No code reads these fields.** Usage rendering
still goes through `dsl/usage.rs::matched_entry`. Help
text still hand-curated.

When `dsl/usage.rs::REGISTRY` retires (§1 above), wire
the parse-error renderer and the in-app help system to read
these fields directly.

## Sharp edges to know about

These are facets of the walker's behaviour that aren't bugs
but that will surprise someone reading the code cold.

- **Optional backtracking on partial-match** is intentional
  (matches chumsky's `or_not` semantics). When an Optional's
  inner consumes some terminals and then fails (Incomplete or
  Mismatch), the walker rolls back the path / per_byte state
  to the pre-Optional position and treats it as skipped, with
  the inner's expectations carried as `skipped` on the
  Matched return. **Validation failures (content errors) do
  NOT backtrack** — the user means to fix those. This
  asymmetry is the load-bearing decision that makes
  `create table T with` produce the correct
  `IncompleteAtEof` classification (chumsky's behaviour).
- **Walker's `Choice` is strictly greedy** — first child
  whose first terminal matches wins. No backtracking. Required
  ordering: more-specific shapes before more-general. See §9
  above for examples.
- **`Ident { source: Tables/Columns/Relationships }` does NOT
  validate against the schema at parse time.** It's a
  shape-only check today. Schema-aware parse is the Phase D
  vision; see §2.
- **`Literal(&'static str)` matches verbatim bytes with a
  word-boundary lookahead** so `1` doesn't half-match `12`
  and `n` doesn't half-match `name`. The highlight class is
  inferred from the literal's bytes (digits → Number, else
  Keyword). Used today only for the `1` in `add 1:n
  relationship`.
- **`AST builder` failures surface as `WalkOutcome::ValidationFailed`
  with `at_eof = true`**. The bridge maps these to
  `ParseError::Invalid` with `at_eof: true` so the input
  renderer classifies them as `IncompleteAtEof` (no live
  overlay; on-submit error fires). This mirrors the chumsky-
  side custom-error convention.
- **`unknown_command_error` is the sole catch-all** for
  inputs whose first identifier-shape token isn't a registered
  entry word. Wording: "expected one of `add`, …, found
  `<word>`". Position is the start of the unknown word.
- **`q` quit alias remains gone.** The walker SUPPORTS
  aliases natively — adding `q` back is a one-line change on
  `QUIT.entry.aliases` (see §6).
- **Path-bearing UX change shipped (Phase A + E):** `replay`,
  `import`, `export` paths terminate at the first whitespace
  byte. Paths with spaces use the quoted form
  (`replay 'my project/seed.commands'`). This is per ADR-0024.

## Suggested next-session priorities

In order:

1. **Phase F (full): consumer migration to walker outputs.**
   This is the biggest deferred chunk and the right next
   structural move. See §1 above for the migration sketch.
   ~1 session for the highlighting + completion + usage
   migration; one follow-up commit for the catalog cleanup
   and lexer/keyword/ident_slot deletion.
2. **Phase D (full): schema-aware value typing.** Once the
   schema cache plumbing exists, the `DynamicSubgrammar`
   wiring + typed value slots are mechanical. See §2.
3. **Walker-driven completion** (§3). Smaller scope than the
   above. Surfaces `IdentSource` + `role` directly to the
   completion engine without the ParseError-string round
   trip. Unlocks better hint UX.
4. **`HintMode` annotations** (§4). Mechanical migration of
   the ad-hoc hint cases in `input_render.rs` to node
   annotations. Small.
5. **Ranker hook** (§5). Future work. Plug-in point for
   frequency-based ranking, content-aware priors, recency.

After (1) and (2) land, the codebase reaches the steady-state
ADR-0024 envisioned: one declaration per command, no scatter,
walker as single source of truth across parse / complete /
highlight / hint / usage.

## ADR index (read these before touching the related areas)

```
0000 Record architecture decisions (process)
0001 Language and TUI framework (Rust + Ratatui)
        — chumsky dependency dropped in Phase F minimal
0002 Database engine (User-facing posture)
0003 Input modes and command dispatch
0004 Project file format (amended by 0015)
0005 Column type vocabulary
0006 Undo snapshots and replay log (designed, not impl)
0007 Sharing and export (amended by 0015 amendment 1)
0008 Testing approach
0009 DSL command syntax conventions
0010 Database access via worker thread
0011 FK column type compatibility
0012 Internal metadata for user-facing column types
0013 Relationships, naming, and rebuild-table strategy
0014 Data operations, value literals, and auto-show
0015 Project storage runtime
0016 Pretty table rendering for data and structure views
0017 Column type-change compatibility
0018 Auto-fill contracts for serial and shortid columns
0019 Friendly error layer (H1) and i18n message catalog
        — `parse.token.keyword.*` collapse pending Phase F full
0020 Tokenization layer for the DSL parser
        — superseded by the scannerless walker in ADR-0024;
          the lexer module survives until Phase F full
0021 Parser-as-source-of-truth for H1a
        — usage info migration to grammar nodes pending
          (CommandNode.usage_id declared, not consumed yet)
0022 Ambient typing assistance (I3 + I4 unified)
        — completion still chumsky-bridge; walker direct
          path pending
0023 Unified declarative grammar tree (direction)
        — superseded for execution by ADR-0024
0024 Unified grammar tree: execution plan (ACCEPTED)
        — A through F minimal landed; F full + D full deferred
```

## Repository layout (delta vs. handoff-8)

New files this session:

```
src/dsl/grammar/
  mod.rs        (267)  — Node enum, Word, IdentSource, HintMode,
                          HighlightClass, ValidationError,
                          IdentValidator, NumberValidator,
                          CommandNode, REGISTRY (20 commands)
  app.rs        (272)  — 11 app-lifecycle commands
  ddl.rs        (492)  — drop, add, rename, change, create
  data.rs       (504)  — show, insert, update, delete, replay
  shared.rs     (108)  — type validator, qualified_column,
                          referential_clauses, action_keyword
src/dsl/walker/
  mod.rs        (~620) — walk() entry + 53 walker tests
  driver.rs     (~570) — per-node-kind dispatch with backtracking
  context.rs    (43)   — WalkContext (schema fields stubbed)
  outcome.rs    (~165) — WalkResult, WalkOutcome, MatchedPath
  lex_helpers.rs (~190) — byte-level helpers (skip_whitespace,
                          consume_ident, match_keyword,
                          consume_bare_path, consume_flag,
                          consume_number_literal, consume_string_literal)
```

Files modified this session:

```
src/dsl/mod.rs          — wire grammar + walker modules
src/dsl/parser.rs       — chumsky combinators deleted; now a
                          ~290-line walker bridge with the
                          original test suite (~840 lines) intact
Cargo.toml              — chumsky dependency removed
Cargo.lock              — regenerated
```

Files NOT touched but worth knowing about (still consume the
legacy modules per §1):

```
src/dsl/lexer.rs        — still exports lex() / Token / TokenKind
src/dsl/keyword.rs      — Keyword enum still alive
src/dsl/ident_slot.rs   — IdentSlot enum still alive
src/dsl/usage.rs        — REGISTRY + matched_entry still alive
src/completion.rs       — reads ParseError::Invalid::expected
                          (bridge from walker), uses Keyword +
                          IdentSlot
src/input_render.rs     — uses lex() for token-class colouring
src/theme.rs            — token colour mappings keyed on Keyword
src/runtime.rs, src/db.rs — IdentSlot::expected_label round-trip
```

## How to take over

1. **Read this file.**
2. **Read `CLAUDE.md`** for the working-style rules. Note
   the "Escalate ambiguity — do not decide for the user"
   rule. The deferred items below are scoped enough that
   most decisions are clear; escalate if the spec genuinely
   disagrees with the implementation.
3. **Read ADR-0024**
   (`docs/adr/0024-unified-grammar-tree-execution-plan.md`)
   to understand the design intent. Phase F (full) and Phase D
   (full) are the unfinished work.
4. **Skim `src/dsl/grammar/mod.rs`** — the Node enum + Word
   + CommandNode + REGISTRY are the contract.
5. **Skim `src/dsl/walker/mod.rs`** — the walk() entry +
   bridge logic. The 53 tests at the bottom of that file
   are the behavioural spec.
6. **Run `cargo test`** to confirm the 844-test baseline.
   Lib test count is 711 in `rdbms_playground` (the rest are
   integration + doctests). Total should be 844 passed, 0
   failed, 1 ignored.
7. **Run `cargo clippy --all-targets -- -D warnings`** to
   confirm clean baseline.
8. **Pick a deferred item from §1-§5** and start. §1
   (Phase F full) is the natural next move; it unblocks §3
   (walker-driven completion) and §4 (HintMode annotations).
   §2 (Phase D full) is the second-largest item and can
   land in parallel since it touches the grammar layer
   rather than the consumer layer.

### End-to-end smoke test (current state, post-ADR-0024)

```
$ rm -rf /tmp/handoff9-smoke
$ rdbms-playground --data-dir /tmp/handoff9-smoke

# Inside the app:

# All commands now route through the walker. User-visible
# behaviour is unchanged from handoff-8 except for:
# - import / export paths with spaces require quotes
# - replay paths with spaces require quotes
# - parse error wording uses "after `<consumed>`, expected …,
#   found end of input" framing (matches the chumsky-era
#   contract; tests pin this down)

# Smoke commands:
help                                         -- in-app help
mode advanced                                -- mode switch
quit                                         -- exit (no `q`)
:quit                                        -- one-shot escape
                                                also works

# DDL:
create table Customers with pk
add column Customers: Email (text)
add 1:n relationship from Customers.id to Orders.customer_id

# Data:
insert into Customers values (1, 'Alice')
update Customers set Email='new@b.c' where id=1
delete from Customers where id=1
show data Customers
show table Customers

# Replay:
replay history.log
replay 'my project/seed.commands'

# Errors:
frobulate widgets
# → expected one of `add`, `change`, `create`, `delete`,
#   `drop`, `export`, `help`, `import`, `insert`, `load`,
#   `messages`, `mode`, `new`, `quit`, `rebuild`, `rename`,
#   `replay`, `save`, `show`, or `update`, found `frobulate`

mode bogus
# → unknown mode 'bogus' (expected 'simple' or 'advanced')

change column T: c (int) --force-conversion --dont-convert
# → `--force-conversion` and `--dont-convert` are mutually
#   exclusive — pick one.

create table Customers
# → tables need at least one column. Add `with pk` for a
#   default `id INTEGER PRIMARY KEY`, or `with pk <name>:<type>`
#   …

# All wording sourced from the en-US.yaml catalog via the
# walker's ValidationError catalog-key mechanism.
```

After Phase F full lands, this smoke test extends with:
- Per-keystroke highlighting driven by walker `per_byte_class`
- Cursor-position completion driven by walker direct path
- Usage templates rendered from `CommandNode.usage_id`
- Lexer/Keyword/IdentSlot modules removed from the source tree
