diff --git a/docs/adr/0015-project-storage-runtime.md b/docs/adr/0015-project-storage-runtime.md
index 7730fc5..ddadd54 100644
--- a/docs/adr/0015-project-storage-runtime.md
+++ b/docs/adr/0015-project-storage-runtime.md
@@ -213,6 +213,14 @@ working copy.
### 6. Persistence ordering
+> **Amended by ADR-0052 (2026-06-13, issue #30):** `history.log` is no
+> longer written inside the worker transaction. It is a *journal* of typed
+> commands, not state, so success journaling moved to the dispatch layer
+> (next to the already-top-level failure journaling); `commit-db-last` now
+> governs the three **state** targets only (db + `project.yaml` +
+> `data/*.csv`), which still commit atomically in the worker. The journal
+> write is best-effort (amends ADR-0040).
+
A successful user command produces effects in four targets:
the SQLite database, `project.yaml`, the relevant
`data/
.csv` file(s), and `history.log`. INV-2 from the
diff --git a/docs/adr/0016-pretty-table-rendering.md b/docs/adr/0016-pretty-table-rendering.md
index 3a2b3d8..26dfc10 100644
--- a/docs/adr/0016-pretty-table-rendering.md
+++ b/docs/adr/0016-pretty-table-rendering.md
@@ -197,6 +197,16 @@ Referenced by:
The relationship sections retain today's plain-text format
to leave room for the future relationship-rendering ADR.
+> **Superseded.** ADR-0044 replaced this prose block with compact
+> diagrams on relationship-subject surfaces (`show table`,
+> `add`/`drop relationship`). **ADR-0050 (2026-06-12, issue #28)** then
+> removed the relationship block entirely from incidental-DDL structure
+> echoes (`create table`, `add`/`drop`/`rename`/`change column`,
+> `add`/`drop index`) — those render structure only — and **deleted the
+> prose renderer**. The `References:` / `Referenced by:` format above is
+> retained here as documentation/provenance should the OOS-7
+> always-prose display setting ever be built.
+
### 6. Theme integration
Theme colors apply to the box-drawing characters via the
diff --git a/docs/adr/0022-ambient-typing-assistance.md b/docs/adr/0022-ambient-typing-assistance.md
index 254b45e..1150d34 100644
--- a/docs/adr/0022-ambient-typing-assistance.md
+++ b/docs/adr/0022-ambient-typing-assistance.md
@@ -772,6 +772,58 @@ invalid_ident_does_not_fire_for_column_prefix_at_sql_expr_slot}`;
`theme::function_colour_is_distinct_from_keyword_identifier_and_type`.
See ADR-0031's status note for the grammar-side anchor.
+## Amendment 7 — optional positional args reach the hint panel (2026-06-12)
+
+Issue #26. At `seed
▮` the hint panel showed only the
+`set` / `--seed` continuation chips and never mentioned the
+**optional row count** — even though a count (`seed users 50`) is
+the most common next move. The count is a bare positional
+`NumberLit` with no keyword/candidate text, so the candidate ladder
+can't surface it; and `seed
` is already a *complete*
+command, so the hint resolver short-circuits (empty expected set).
+
+The existing `IntroProse` `HintMode` (ADR-0024 §HintMode-per-node;
+issue #4's CREATE-TABLE element hint) is the right tool — it shows
+prose that *introduces* a position whose first-class move has no
+candidate, with the keyword alternatives folded into the prose and
+Tab still cycling them. But it did not reach this position: a
+`Node::Hinted`'s mode lives in `pending_hint_mode`, which the very
+next match clears — including the **empty** match of a skipped
+`Optional`. The CREATE-TABLE element survives only because it sits
+in a *required* `Repeated(min:1)`; an optional positional followed
+by more optionals (the seed count) is cleared before the resolver
+reads it.
+
+### Mechanism
+
+A small, general carry: when `walk_optional` skips its inner (the
+inner didn't engage), it stashes any `IntroProse` key the inner
+left in `pending_hint_mode` into a new `WalkContext` field,
+`surviving_intro_hint: Option<(key, position)>`, **before** the
+empty match clears `pending_hint_mode`. The trailing optionals,
+which are not `IntroProse`, don't overwrite it. The hint snapshot
+keeps the key **only when `position == cursor`** (the slice end),
+so it shows while the cursor sits at the count slot but not once a
+later clause (`set …`) consumes input past it, nor once the count
+itself is supplied. The resolver returns that `IntroProse` even for
+an otherwise-complete command (ahead of the empty-expected
+short-circuit).
+
+The seed grammar wraps the count in
+`Hinted { IntroProse("hint.seed_count"), NumberLit }`; the prose
+names the count (with its default 20) plus the `.column`
+column-fill form and the `set` / `--seed` keywords (user-chosen
+scope: mention every option). Only `IntroProse` is carried —
+`ProseOnly` / `ForceProse` mark *active* slots and reach the
+resolver through the normal path, unchanged. The CREATE-TABLE
+element (in a `Repeated`, not an `Optional`) is untouched.
+
+This is a refinement of ADR-0024 §HintMode-per-node and a sibling
+of issue #4; no `AmbientHint` / renderer change. Covered by
+`input_render::{seed_count_is_advertised_at_the_optional_position,
+seed_count_hint_does_not_leak_once_the_count_or_a_clause_is_given,
+seed_count_hint_also_fires_after_a_column_fill_target}`.
+
## Out of scope
Deliberately deferred to keep this ADR shippable as a single
diff --git a/docs/adr/0034-history-journal-and-replay-filter.md b/docs/adr/0034-history-journal-and-replay-filter.md
index 744c97a..ff2ef63 100644
--- a/docs/adr/0034-history-journal-and-replay-filter.md
+++ b/docs/adr/0034-history-journal-and-replay-filter.md
@@ -2,7 +2,13 @@
## Status
-Accepted
+Accepted. **Amended by ADR-0052 (2026-06-13, issue #30):** the status
+field gains an optional `:adv` mode suffix (`ok:adv` / `err:adv`) — the
+"non-breaking future extension" this ADR reserved — and **success
+journaling moves out of the worker to the dispatch layer**
+(`spawn_dsl_dispatch` / `run_replay` / app-command sites), next to the
+failure path, where the submission mode is in scope. `status_is_ok` keys
+off the base token, so `ok:adv` replays like `ok`.
## Context
diff --git a/docs/adr/0040-completion-marker-replaces-ok-summary.md b/docs/adr/0040-completion-marker-replaces-ok-summary.md
index bc7cd3a..b90b4e6 100644
--- a/docs/adr/0040-completion-marker-replaces-ok-summary.md
+++ b/docs/adr/0040-completion-marker-replaces-ok-summary.md
@@ -5,7 +5,11 @@
**Accepted** — 2026-05-30 (issue #9). Amends the output conventions of
ADR-0014 (data operations), ADR-0028 (query plans / `explain`), and
ADR-0019 (failure rendering); builds on ADR-0037's mode-tagged echo
-line.
+line. **Amended by ADR-0052 (2026-06-13, issue #30):** a `history.log`
+*journal*-write failure on a **successful** command is no longer fatal —
+journaling moved to the dispatch layer (after the db commit), so it is
+best-effort (logged + ignored), consistent with the failure-journal path.
+State-write failures (yaml/csv/db) remain fatal.
## Context
diff --git a/docs/adr/0044-relationship-visualization.md b/docs/adr/0044-relationship-visualization.md
index 1210646..dfbb203 100644
--- a/docs/adr/0044-relationship-visualization.md
+++ b/docs/adr/0044-relationship-visualization.md
@@ -103,6 +103,10 @@ Prose-retained surfaces (**unchanged** from ADR-0016 §5):
`add`/`drop index` — keep the terse `References:` /
`Referenced by:` prose. A simple `add column` on a heavily-related
table should not print a wall of diagrams.
+ *(**Superseded 2026-06-12 by ADR-0050** (issue #28): these incidental
+ DDL echoes now render **structure only** — no relationship block at
+ all, neither prose nor diagram. The prose renderer was deleted. The
+ diagram surfaces below are unchanged.)*
So this **partially supersedes ADR-0016 §5**: the prose block is
replaced by diagrams on the relationship-subject surfaces and
diff --git a/docs/adr/0046-sidebar-navigation-and-responsive-input-hint.md b/docs/adr/0046-sidebar-navigation-and-responsive-input-hint.md
index 3305a11..a0573ad 100644
--- a/docs/adr/0046-sidebar-navigation-and-responsive-input-hint.md
+++ b/docs/adr/0046-sidebar-navigation-and-responsive-input-hint.md
@@ -525,7 +525,9 @@ All tiers green, zero skips; clippy clean (nursery).
submits over a multi-logical-line buffer. DA3/DA4 keep a single
logical line; this remains a separate, deferred feature.
- **Readline shortcuts (I1b)** — Ctrl-A/E/W/K/U stay reserved-deferred;
- not touched here.
+ not touched here. *(Superseded 2026-06-12: I1b is now in scope and
+ decided by **ADR-0049** — Esc-clear + Ctrl-A/E/W/K/U in the input
+ field, issue #29.)*
- **Cross-session sidebar persistence** — visibility is session-only
(DB1); persisting it would amend ADR-0015.
- **The output panel as a third navigation focus target** — navigation
@@ -554,3 +556,27 @@ All tiers green, zero skips; clippy clean (nursery).
and is accepted: 90 is the screencast width, real terminals sit well
to one side of it, and `Ctrl-O` peek covers the in-between case. The
`90` threshold is a tunable constant.
+
+## Amendment 1 — focus accent is a colour, not bold (2026-06-12)
+
+Issue #25. DC3's "accent border" on the focused sidebar panel was
+first implemented as bright `theme.fg` **plus `Modifier::BOLD`** on
+the box-drawing border. Bold box-drawing glyphs render as broken /
+gapped line-art in the asciinema player used for the website casts
+(vertical strokes don't connect to the corner glyphs) and are
+fragile in some terminals.
+
+**`panel_border_style` now marks focus with a non-bold accent
+colour — `theme.mode_simple` (blue) — and never `Modifier::BOLD` on
+a border.** The unfocused border stays muted `theme.border`. This
+makes the ADR's "accent border (lazygit convention)" wording
+literal — it is now a true accent hue rather than bold bright-fg —
+and is what renders cleanly in casts. Bold remains fine on *text*
+spans (titles, key hints); the constraint is specifically that
+box-drawing borders carry no bold attribute.
+
+Note: this is a pure style change. The Tier-2 snapshots are
+text-only (`render_to_string` captures cell symbols, not styles),
+so none needed re-accepting; the Tier-1 `panel_border_style`
+assertion was updated and a render-level test now checks the actual
+border cells carry the accent colour and no bold.
diff --git a/docs/adr/0048-seed-fake-data-generation.md b/docs/adr/0048-seed-fake-data-generation.md
index 9dfd1ed..b3cbe83 100644
--- a/docs/adr/0048-seed-fake-data-generation.md
+++ b/docs/adr/0048-seed-fake-data-generation.md
@@ -317,6 +317,8 @@ with the implementation):
| `url`/`website`/`homepage` · `color`/`colour` | URL / hex colour | text |
| `price`/`amount`/`cost`/`salary`/`balance`/`total` | currency-range number | numeric |
| `age` · `quantity`/`qty`/`stock`/`count` | 18–80 · small int | numeric |
+| `year`/`*_year`/`published`/`founded` (Amendment 1) | bounded year (birth window for `birth`/`born`/`dob`, else 1950–2025) | int |
+| `priority`/`prio` · `severity` · `rating`/`stars` (Amendment 1) | built-in `PickFrom` value set | text/int |
| `date`/`*_date` | date, recent ~3 yr window | date |
| `dob`/`birthday` | date, adult window (18–80 yr ago) | date |
| `timestamp`/`datetime` · `created_at`/`updated_at`/`*_at` | datetime, recent window (`updated_at` ≥ `created_at`) | datetime |
@@ -675,3 +677,66 @@ the regression floor.
derive-`IN`-else-friendly-fail tier.
- **`set`-driven NULL / per-column report / recursive parent seed:**
deferred — see Out of scope.
+
+## Amendment 1 — year-as-int + conventional choice sets (2026-06-12)
+
+Two SD2-style refinements to the D7 catalogue, surfaced while writing
+the website `seed` docs. Both are additive name rules; no change to D8
+(type fallback), the executor, or the grammar.
+
+### Issue #33 — year-like `int` columns
+
+A column such as `published` or `birth_year` was just an `int`, so it
+fell through to the unbounded type-based `int` path (D8) and produced
+nonsense like `9419` or `1426` — implausible as years, undercutting the
+"realistic data" pedagogy. Added an **`int`-gated** year rule, placed
+*after* the quantity rule (so `year_count` stays a count):
+
+- `year` / `*_year` / `published` / `founded` → **`YearRecent`**, a
+ bounded window of **1950–2025** (75 years relative to the fixed
+ `REF_YEAR`, wide enough for published books / founding years /
+ release years; matches the issue's own `between 1950 and 2020`
+ workaround).
+- the same with a `birth` / `born` / `dob` token (e.g. `birth_year`) →
+ **`YearBirth`**, mirroring the existing `dob → DateAdult` adult birth
+ window as years (**1945–2007**).
+
+Both emit a plain `int`. `published` / `founded` are included
+(user-confirmed): an `int` so named is almost always a year (a flag
+would be `is_published`). The generators are **not** added to the D9
+named-generator vocabulary — explicit control stays with `set
+between and `.
+
+### Issue #34 — built-in value sets for conventional choice names
+
+D12 deliberately does not guess values for enum-ish names. For a few,
+though, there is a near-canonical small set that reads far better than
+lorem text. Added a **type-gated `PickFrom`** lookup (reusing the
+existing generator — no new machinery), placed ahead of the enum-ish
+fallthrough:
+
+| Name (tokens) | text | int |
+|---|---|---|
+| `priority` / `prio` | `low`/`medium`/`high` | `1`/`2`/`3` |
+| `severity` | `low`/`medium`/`high`/`critical` | `1`/`2`/`3`/`4` |
+| `rating` / `stars` | — | `1`–`5` |
+
+A user-declared `IN`-CHECK (D17) still wins — it is resolved before the
+heuristics. Any name that gains a set is **removed from the enum-ish
+advisory trigger** (`priority` left `ENUM_TOKENS`); since the advisory
+(D13) only fires on `Generator::Generic`, a `PickFrom` name is excluded
+either way, but the removal keeps `is_enum_ish` semantically "names seed
+still can't guess".
+
+**`status` is deliberately excluded** (user-confirmed on the issue): its
+real values are too domain-specific (`active/inactive`,
+`open/closed/pending`, `draft/published`, …), so it keeps the D12
+"don't guess" stance — generic text + the advisory pointing at `set
+status in (…)`. `state` stays its US-state-name generator (D7);
+`type`/`kind`/`category`/`stage`/`gender` and `size`/`tier`/`plan` were
+considered and left to the advisory.
+
+**Website follow-up** (tracked on the `website` branch, not here): the
+`seed` cast exercises a `tickets` table with `priority`; it should be
+re-recorded so the table tightens once `priority` collapses to a short
+value — likely subsumed by the pre-publication cast sweep.
diff --git a/docs/adr/0049-input-field-readline-keymap.md b/docs/adr/0049-input-field-readline-keymap.md
new file mode 100644
index 0000000..841df2b
--- /dev/null
+++ b/docs/adr/0049-input-field-readline-keymap.md
@@ -0,0 +1,114 @@
+# ADR-0049: Input-field readline keymap — Esc-clear + Ctrl-A/E/W/K/U (I1b)
+
+## Status
+
+**Accepted + implemented 2026-06-12 (issue #29).** Closes Gitea **#29**
+("Command input keystroke support") and the deferred **I1b** readline
+requirement in `requirements.md`. Every fork below was escalated to the
+user and user-chosen before any code was written; implemented test-first
+(22 new Tier-1 tests in `src/app.rs`, all green; clippy nursery clean).
+
+This ADR **amends ADR-0046**, which explicitly listed "readline
+shortcuts (I1b)" in its out-of-scope set: that item is now in scope and
+decided here. It is orthogonal to ADR-0003's input-*mode* model (simple
+vs advanced, the `:` sigil) — these are editing keys within the input
+field, not mode or sigil changes — and it extends the single-line cursor
+editing already shipped under requirement **I1a** (Left/Right/Home/End/
+Backspace/Delete, `app.rs`).
+
+## Context
+
+The input field already supported in-line cursor editing (I1a): Left/
+Right by char (UTF-8 aware), Home/End to the extremes, Backspace/Delete.
+Two gaps remained, raised in issue #29:
+
+1. No way to **clear a partly-typed command** in one keystroke — a user
+ who started typing the wrong thing had to hold Backspace.
+2. No **readline cursor/kill shortcuts** (Ctrl-A/Ctrl-E and friends) for
+ keyboards without Home/End and for muscle-memory in a command-driven
+ workflow. This is requirement I1b, deferred by ADR-0046.
+
+`Esc` was free in the input field except that a *live Tab-completion
+memo* consumes it first (to undo the completion in one keystroke,
+ADR-0022). Ctrl-A/E/W/K/U were unbound. The existing chords are Ctrl-C
+(quit), Ctrl-O (nav focus cycle, ADR-0046), and Ctrl-`]` (demo caption
+toggle, ADR-0047) — none collide with a/e/w/k/u.
+
+## Decision
+
+Bind the following in the input field (non-modal, non-navigation,
+both input modes), in `App::handle_key`:
+
+| Key | Action |
+|-----------|---------------------------------------------------|
+| `Esc` | Clear the input (empty buffer, cursor→0, scroll→0)|
+| `Ctrl-A` | Cursor to line start (alias of Home) |
+| `Ctrl-E` | Cursor to line end (alias of End) |
+| `Ctrl-W` | Delete the word before the cursor |
+| `Ctrl-K` | Kill from the cursor to end of line |
+| `Ctrl-U` | Kill from start of line to the cursor |
+
+Behavioural rules:
+
+- **Esc precedence.** A live completion memo still wins: the first Esc
+ undoes the completion (ADR-0022), and Esc only *clears* when no memo
+ is alive. This is a natural progression — Esc once to back out the
+ completion, Esc again to clear.
+- **Esc does not clear while navigating the sidebar.** When a sidebar
+ panel is focused (Ctrl-O, ADR-0046 DC3), `handle_key` routes every
+ key to the navigation handler *before* the input-field keymap, where
+ Esc exits navigation mode (`nav_exit`). Entering nav mode never
+ touched the input buffer, so Esc-to-close-the-panel returns focus to
+ the input with the partly-typed command intact — it cannot reach the
+ clear binding. Locked by a regression test.
+- **Single Esc clears** (user-chosen over double-Esc). Discoverable and
+ fast; the trade-off (an accidental Esc wipes an unsubmitted line) was
+ accepted. A submitted line is always recoverable from history; only
+ *unsubmitted* draft text is lost.
+- **Cursor-only keys don't touch history navigation.** Ctrl-A/Ctrl-E,
+ like Home/End, move the cursor without ending history recall.
+- **Buffer-mutating keys end history navigation.** Esc-clear and
+ Ctrl-W/K/U call `cancel_history_navigation` (the cleared/edited line
+ *is* the new draft), matching Backspace/Delete.
+- **Ctrl-W is readline-style and UTF-8 safe.** It eats any run of
+ trailing whitespace, then the preceding run of non-whitespace; word
+ boundaries are found on char boundaries so multi-byte words delete
+ cleanly. It only ever deletes back to the cursor (a mid-line Ctrl-W
+ leaves the suffix intact).
+
+Helpers added: `clear_input`, `delete_prev_word`, `kill_to_end`,
+`kill_to_start` (`src/app.rs`), mirroring the existing `cursor_left` /
+`delete_before_cursor` style.
+
+## Forks (all user-chosen)
+
+- **Esc semantics:** single-Esc-clears, *not* double-Esc — discoverable
+ over accident-proof.
+- **Scope:** the *full* I1b set (Esc-clear + Ctrl-A/E/W/K/U), not just
+ the issue's literal Ctrl-A/E + Esc — closes the whole I1b requirement
+ in one pass rather than leaving Ctrl-W/K/U for a follow-up.
+- **Documentation:** a new ADR (this one), recording the input-field
+ keymap convention and amending ADR-0046's OOS list — over folding it
+ into ADR-0046 or shipping it I1a-style with no ADR.
+
+## Consequences
+
+- I1b is complete; `requirements.md` I1b moves to `[x]`.
+- The new keys are **not yet advertised on screen.** Surfacing per-focus
+ keybindings in the bottom status line is issue #27's domain (a
+ separate, in-design UX change); this ADR makes the keys *work*, #27
+ will make them *discoverable*.
+- **Demo-mode badges** (ADR-0047) are *not* extended to the new Ctrl-
+ chords here. Esc already badges as `[ESC]`; Ctrl-A/E/W/K/U are
+ glyph-less and would be invisible in an asciinema cast. Whether to add
+ `[CTRL-A]`…`[CTRL-U]` badges is left to ADR-0047's scope and flagged
+ as a follow-up — it is a cast-polish concern, not a #29 requirement.
+
+## Out of scope
+
+- On-screen keybinding hints for the input field (issue #27).
+- Demo badges for the new chords (ADR-0047 follow-up; flagged above).
+- Multi-line input (I1) and its Ctrl-Enter submit — unrelated, still
+ deferred.
+- Word-wise *cursor motion* (Alt-B/Alt-F) and transpose/yank — not
+ requested; not part of I1b.
diff --git a/docs/adr/0050-incidental-ddl-confirmations-omit-relationships.md b/docs/adr/0050-incidental-ddl-confirmations-omit-relationships.md
new file mode 100644
index 0000000..c67e6fc
--- /dev/null
+++ b/docs/adr/0050-incidental-ddl-confirmations-omit-relationships.md
@@ -0,0 +1,119 @@
+# ADR-0050: Incidental-DDL confirmations omit relationship info (structure-only)
+
+## Status
+
+**Accepted + implemented 2026-06-12 (issue #28).** Closes Gitea **#28**.
+Both forks below were escalated to the user and user-chosen before any
+code was written; implemented test-first. **Supersedes** the
+incidental-DDL clause of **ADR-0044 §1** and the part of **ADR-0016 §5**
+that placed a relationship block under every structure echo. The
+diagram behaviour ADR-0044 introduced for relationship-subject surfaces
+is unchanged.
+
+## Context
+
+ADR-0016 §5 rendered a structure box followed by a plain-text
+`References:` / `Referenced by:` relationship block under **every**
+structure echo. ADR-0044 §1 split that by surface:
+
+- **Relationship-subject surfaces** — `show table `,
+ `add 1:n relationship`, `drop relationship`, `show relationship `
+ — render relationships as compact **diagrams** (the user asked for, or
+ acted on, a relationship).
+- **Incidental DDL auto-shows** — `create table`, `add`/`drop`/`rename`/
+ `change column`, `add`/`drop index` — kept the terse **prose** block,
+ with the rationale *"a simple `add column` on a heavily-related table
+ should not print a wall of diagrams."*
+
+Issue #28 reconsiders the deeper question ADR-0044 did not ask: should
+an incidental-DDL confirmation show relationship information **at all**?
+Owner preference: **no.** A confirmation echo should focus on the change
+just made — the new / updated structure — not re-print the table's
+relationships, which the user did not touch. The terse prose was the
+lesser of "prose vs diagram", but the right answer for these surfaces is
+**neither**.
+
+## Decision
+
+**Incidental-DDL confirmation echoes render the structure only** — the
+table-name header, the column / type / constraints box, the `Indexes:`
+section, and the constraint section — with **no relationship section**
+(neither prose nor diagram).
+
+- **Scope: all incidental DDL** (user-chosen, over "just `add column`"):
+ `create table`, `add column`, `drop column`, `rename column`,
+ `change column`, `add index`, `drop index`. The rule is uniform — a
+ structural edit confirms structure, never relationships. (For a
+ freshly `create`d table the relationship section was empty anyway; the
+ rule still applies for consistency of the mental model.)
+- **Relationship-subject surfaces are unchanged.** `show table`,
+ `add`/`drop relationship`, and `show relationship ` still render
+ diagrams. Relationships appear **only** when the user asks for them
+ (`show table` / `show relationship`) or acts on one
+ (`add`/`drop relationship`).
+- **No information is lost.** Anything dropped from an incidental echo is
+ one `show table ` away.
+
+### Mechanism
+
+The `handle_dsl_success` routing (`app.rs`) is **unchanged**: it still
+sends relationship-subject commands to the diagram renderer and
+everything else to `render_structure`. The change is entirely inside
+`render_structure` (`output_render.rs`): it no longer appends the
+relationship block — `render_structure` = structure box + indexes +
+constraints. All of `render_structure`'s callers are incidental DDL
+(verified), so this single edit covers the whole scope with no
+per-command branching.
+
+### Prose renderer disposition
+
+The orphaned prose renderer (`relationship_prose_lines`, and its
+sole helper `cols_disp`) is **deleted** (user-chosen, over retaining it
+dormant). After this change no shipped surface renders the prose form,
+so keeping it would be dead code. The prose format remains documented in
+**ADR-0016 §5** and in git history; if ADR-0044's OOS-7 user-configurable
+"always-prose" display setting is ever built, it re-introduces the ~30
+lines from that provenance.
+
+## Forks (all user-chosen)
+
+- **Scope:** *all incidental DDL*, not just `add column` — the owner's
+ rationale ("confirm the change, not untouched relationships") applies
+ uniformly, gives a clean mental model, and is the simpler edit (remove
+ one call vs a per-command flag).
+- **Prose renderer:** *delete* it — no dead code — over retaining a
+ public, tested-but-uncalled renderer for the speculative OOS-7 setting.
+
+## Consequences
+
+- Incidental confirmations are shorter and on-topic; a heavily-related
+ table no longer prints a relationship wall after `add column`.
+- One relationship renderer (prose) leaves the codebase; the diagram
+ renderer (ADR-0044) is the only relationship render path that ships.
+- `requirements.md` is unaffected (this is an ADR-tracked refinement of a
+ decided area, like ADR-0044 itself); the change is cross-referenced
+ from the commit + this ADR.
+
+## Tests
+
+- **Unit (`output_render.rs`):** the prose-asserting test
+ `render_structure_with_relationships` (+ its snapshot) is removed; a
+ new test asserts `render_structure` on a description **carrying** both
+ inbound and outbound relationships emits the structure box but **no**
+ `References:` / `Referenced by:` lines. The box/index/constraint tests
+ are unaffected (their descriptions have no relationships).
+- **Integration (`walking_skeleton.rs`):** the misnamed
+ `add_relationship_flow_shows_inbound_section_on_parent` (which sends an
+ `AddColumn` and asserted the inbound prose) is inverted + renamed to
+ assert the add-column confirmation shows the structure but **omits**
+ the relationship prose.
+- **Unchanged:** the diagram tests (`show_list.rs` `show table`,
+ `walking_skeleton.rs` `add relationship`) still pass — they already
+ assert prose is absent and diagrams are present.
+
+## Out of scope
+
+- The diagram form and its per-surface defaults (ADR-0044) — unchanged.
+- The OOS-7 user-configurable display setting (always-prose / -diagram /
+ auto-by-width) — still a future follow-up; this ADR removes the prose
+ *renderer*, not the *idea* of a prose mode.
diff --git a/docs/adr/0051-context-state-aware-keybinding-strip.md b/docs/adr/0051-context-state-aware-keybinding-strip.md
new file mode 100644
index 0000000..bc50aab
--- /dev/null
+++ b/docs/adr/0051-context-state-aware-keybinding-strip.md
@@ -0,0 +1,147 @@
+# ADR-0051: Bottom keybinding strip — context- and state-aware
+
+## Status
+
+**Accepted 2026-06-13 (issue #27).** Closes Gitea **#27**. All forks
+below were escalated to the user and user-chosen before any code was
+written; to be implemented test-first. Builds on ADR-0046 (nav focus),
+ADR-0003 (input modes), ADR-0049 (the #29 readline keys this strip now
+advertises), and ADR-0022 (the Tab-completion memo).
+
+## Context
+
+The bottom status line (`render_status_bar`, `ui.rs`) mixed keystrokes
+with typed-command words: `Enter submit · : advanced once · mode
+advanced switch · Ctrl-C quit`. That is redundant — the hint panel
+already teaches `help` and `Enter` when the input is empty — and it is
+static apart from a three-way mode branch, so it never reflects what the
+user can actually do *right now* (navigating the sidebar, cycling a
+completion, browsing history, editing a line).
+
+Issue #27: repurpose the line as a **keybindings-only** strip that is
+**context-sensitive to nav focus** and **state-aware of the current
+transient interaction**, and move mode discovery into the empty-input
+hint.
+
+## Decision
+
+### 1. The strip is keybindings-only and state-selected
+
+A single pure function `status_bar_bindings(app) -> Vec`
+computes the strip from app state; `render_status_bar` is a thin
+renderer over it (so the binding sets are unit-testable without a
+Frame). `history_cursor` is private to `App`, so a small
+`pub fn is_browsing_history(&self) -> bool` accessor exposes the
+history-navigation predicate; `mode` / `nav_focus` / `last_completion`
+are already `pub` and `effective_mode()` is a `pub` method. The state is
+chosen by **priority — first match wins**:
+
+| Priority | State (predicate) | Strip |
+|---|---|---|
+| 1 | **Sidebar focus** (`nav_focus` in a sidebar) | `Ctrl-O next pane · ↑↓/PgUp/PgDn scroll · Esc input` |
+| 2 | **Completion memo live** (`last_completion.is_some()`) | `Tab/Shift-Tab cycle · Esc cancel · Enter run` |
+| 3 | **History navigation** (`history_cursor.is_some()`) | `↑↓ browse · Esc clear · Enter run` |
+| 4 | **Editing** (Input focus, input non-empty) | `Esc clear · Ctrl-A/E home/end · Ctrl-W del word · Enter run` |
+| 5 | **Default** (Input focus, input empty) | `Ctrl-O sidebar · Tab complete · ↑ history · Enter run` |
+
+Priority order matters: a completion memo or history navigation is a
+non-empty-input situation, so states 2 and 3 must precede state 4. The
+sidebar overlay occludes the input entirely (ADR-0046), so state 1 wins
+outright.
+
+### 2. Mode discovery moves off the strip, into the empty-input hint
+
+The typed-command advertisements (`mode advanced` / `mode simple`
+switch, the `:` one-shot) leave the strip — they are not keystrokes.
+Mode discovery moves to the **empty-input hint** (`resolve_hint_lines`'s
+`(None, None)` arm), in **simple mode only**:
+
+- **Simple:** `… · \`mode advanced\` for SQL`
+- **Advanced (persistent):** no pointer.
+
+The pointer omits the verb "type" — the surrounding prompt already
+implies it (we don't say "type `help`" either). Advanced mode shows
+**no** pointer (user decision, post-trial): a user who switched into
+advanced mode knows how they got there, and `help` covers the way back —
+a "switch back" pointer only reads naturally in the moment right after
+switching, so it earns its space poorly.
+
+The one-shot advanced state's old `Backspace cancel one-shot` label is
+**subsumed** by the editing state (the input is non-empty in one-shot;
+Esc-clear and Backspace both cancel it). No behaviour is lost — only the
+dedicated label.
+
+### 3. Width: no drop machinery; a budget test instead
+
+The longest strip (state 4, editing) is ≈ **65 display columns**, which
+fits every supported width (90-col screencasts, 80-col terminals) with
+margin — so the priority-drop / abbreviation machinery considered would
+never trigger and is not built (user-confirmed). Ratatui's existing
+**clip-at-edge** is the trivial fallback for pathologically narrow
+(< 65-col) terminals. Instead, a **width-budget unit test** pins the
+longest rendered strip within an 80-col budget, keeping the strip lean
+*by construction* — a future over-long strip fails the test rather than
+silently clipping in a cast.
+
+## Forks (all user-chosen)
+
+- **Editing state — yes:** when the input has text, surface the #29
+ readline keys (Esc-clear, Ctrl-A/E, Ctrl-W); the strip stays lean
+ (nav/complete/history) when empty. (vs not advertising the #29 keys.)
+- **`Ctrl-C quit` — omitted** from the strip (vs always shown): quit is
+ a near-universal convention; omitting it keeps the strips lean and
+ matches the issue's sketch.
+- **Width — budget test, no drop logic** (vs graceful priority-drop /
+ abbreviation): the strips fit at supported widths, so the machinery
+ would be dead weight (user's own observation).
+
+## Consequences
+
+- The strip now teaches the keys for the *current* situation; learners
+ see `Tab/Shift-Tab cycle` exactly while cycling, the editing keys
+ exactly while editing, etc.
+- The #29 readline keys (ADR-0049) gain their on-screen advertisement,
+ closing that ADR's deferred item.
+- 15 existing full-panel insta snapshots churn (the bottom line — and,
+ on empty-input views, the hint pointer — changes in every one,
+ including the rebuild-confirm modal view, whose modal box is itself
+ unchanged); each diff was reviewed, not blind-accepted.
+- `requirements.md` is unaffected (an ADR-tracked UI refinement); the
+ change is cross-referenced from the commit + this ADR.
+
+## Tests
+
+- **Tier-1 (`ui.rs` unit):** `status_bar_bindings` returns the expected
+ key set for each of the five states (sidebar, completion-live,
+ history-nav, editing, default) — the completion/history states driven
+ through real key events (`update`) so the predicate transitions are
+ exercised, the others by setting `App` fields; plus the width-budget
+ assertion across states. (Per-state coverage is these unit tests, not
+ snapshots — a one-line strip is asserted more precisely by its exact
+ key list than by a full-panel snapshot.)
+- **Tier-1:** the empty-input hint appends the correct mode pointer in
+ Simple vs Advanced, and does **not** append it when an ambient hint is
+ showing (non-empty input).
+- **Tier-3 (`walking_skeleton`):** the old `status_bar_lists_quit_and_
+ submit_in_all_modes` (which asserted the pre-ADR strip) is rewritten +
+ renamed to assert the keystroke-only, state-aware strip end-to-end
+ through the real render path (default → editing transition).
+- **Tier-2 (insta):** the 15 full-panel snapshots re-accepted (each diff
+ reviewed — strip line and/or hint pointer only).
+
+## Out of scope
+
+- **Modal-aware strip.** While a modal is open (load picker, rebuild /
+ undo confirm) it owns the keyboard and carries its own in-box key
+ hints; the bottom strip under a modal computes from input state
+ exactly as it does today (modals render *over* the status bar). This
+ issue does not redesign the modal case — pre-existing behaviour,
+ unchanged and not worsened.
+- A persistent/togglable help overlay listing *all* keys (the strip is a
+ contextual subset, not a cheatsheet).
+- Per-key colour theming beyond the existing key/label/separator styles.
+- Localisation of the new label strings beyond adding catalog entries.
+- The remaining I1b kill keys' (Ctrl-K/Ctrl-U) advertisement — the
+ editing strip shows the highest-value subset (Esc/Ctrl-A/E/Ctrl-W) to
+ stay within the width budget; Ctrl-K/U remain unadvertised muscle
+ memory.
diff --git a/docs/adr/0052-mode-tagged-history-cross-mode-recall.md b/docs/adr/0052-mode-tagged-history-cross-mode-recall.md
new file mode 100644
index 0000000..5efdb2a
--- /dev/null
+++ b/docs/adr/0052-mode-tagged-history-cross-mode-recall.md
@@ -0,0 +1,250 @@
+# ADR-0052: Mode-tagged history for cross-mode recall
+
+## Status
+
+**Accepted + implemented 2026-06-13 (issue #30).** Closes Gitea **#30** —
+both the feature ("reuse advanced history commands in simple mode by
+prepending `:`") and the bug reported in its comment (the `:` one-shot
+prefix lost across sessions). All forks user-chosen before any code.
+**Amends ADR-0034** (journal status field gains a `:adv` tag; *journaling
+moves from the worker to the dispatch layer*), **ADR-0015 §5/§6**
+(history.log leaves the worker transaction — `commit-db-last` now scopes
+yaml/csv/db only), and **ADR-0040** (a success-path journal-write failure
+is best-effort, no longer fatal); references ADR-0003 (the `:` one-shot
+sigil). Plan: `docs/plans/20260613-issue-30-top-of-chain-journaling.md`
+(pre-build `/runda`, then a second `/runda` that drove the journaling
+relocation + the app-command exclusion). **2471 tests pass / 0 fail / 0
+skip (1 ignored), clippy clean.**
+
+> **Why journaling moved (the key architectural turn).** The first draft
+> kept journaling in the worker and threaded the mode down to it (~30-site
+> plumbing). On review the user asked the right question: why is the
+> journal written deep in the worker at all, when the failure path already
+> journals at the top of the chain where command + mode + outcome are all
+> in scope? It shouldn't — `history.log` is a *journal of typed commands*,
+> not *state*. So success journaling moved up next to the failure path
+> (`spawn_dsl_dispatch` / `run_replay` / the app-command sites), the
+> mode-plumbing dilemma dissolved, and the worker's `finalize_persistence`
+> now writes only the state sources (yaml/csv). Consequence: the journal
+> write is best-effort (the command is already committed), consistent with
+> the failure path — see §5.
+
+## Context
+
+The input-history ring and `history.log` carry **no mode information**,
+which causes two coupled problems:
+
+1. **Feature gap.** A command typed in advanced mode (`select * from T`)
+ is stored bare. Recalled in simple mode it is not valid DSL → it just
+ errors. There is no way to know it was an advanced (SQL) command and
+ offer it back in a runnable form.
+
+2. **Bug (issue #30 comment).** A `:`-one-shot advanced command in simple
+ mode recalls correctly **in-session** (the in-memory ring stores the
+ raw `:select 1`), but after quit+resume it comes back **without** the
+ `:` and is unusable. Root cause: the ring stores the raw input
+ (`:select 1`), but the worker journals the **stripped** `effective_input`
+ (`select 1`) — submission strips the `:` before dispatch (ADR-0003) —
+ so the on-disk `source` never carried the `:`, and hydration loses it.
+
+Both reduce to: **history does not record the submission mode**, and the
+in-memory and on-disk representations disagree about the `:`.
+
+## Decision
+
+Record the **submission mode** per history entry, keep the on-disk
+`source` **canonical** (stripped — replay is unaffected), and have
+**recall reconstruct the runnable line** for the current mode.
+
+### 1. In-memory ring stores the `:`-prefixed runnable form
+
+`App.history` stays `Vec` — no type change, so the public ring,
+the `ProjectSwitched` payload, and `seed_history` are untouched. An
+**advanced** entry is stored in its **simple-mode runnable form**, the
+`: `-prefixed string (e.g. `: select * from T`); a **simple** entry is
+stored bare. This is exactly what the in-session one-shot ring already
+does (`:select 1` recalls as typed) — generalised to *persistent*-advanced
+commands too, and made reconstructable on hydration. Because a simple
+DSL command can never begin with `:` (the sole sigil, ADR-0003), a
+leading `:` unambiguously marks an advanced entry.
+
+`submit` builds the stored line from the submission: advanced →
+`": " + effective_input` (the `: ` matches the auto-space the typed
+one-shot inserts), simple → `effective_input`. This is computed **after**
+`effective_input` (today `push_history` runs on the raw `trimmed` before
+stripping; the reorder also drops a bare `:`, which never executed). The
+draft (`history_draft`) stays a plain `String`. `push_history` itself is
+unchanged — it still takes one `&str`.
+
+### 2. Recall strips the `:` for advanced mode
+
+`history_back` / `history_forward` set `self.input` from the stored
+string, then strip a leading `:` **iff the current persistent mode is
+Advanced**:
+
+```
+if self.mode == Mode::Advanced && stored.starts_with(':') { stored[1..].trim_start() } else { stored }
+```
+
+So an advanced entry recalls as `: select * from T` in **simple** mode
+(runs via the one-shot escape — the feature, and the cross-session bug
+fix) and bare `select * from T` in **advanced** mode (runs as SQL). A
+simple entry recalls bare in either mode (simple DSL already runs in
+advanced mode — issue #30). In-session and cross-session paths share the
+same stored form, so they finally agree.
+
+### 3. On-disk: a mode tag in the status field
+
+The record stays three pipe-separated fields `||`
+(so `source` remains the last, pipe-tolerant, canonical field — replay
+reads it unchanged). The **status token** gains an optional `:adv`
+suffix:
+
+| Submission | Success | Failure |
+|---|---|---|
+| Simple | `ok` | `err` |
+| Advanced (persistent or one-shot) | `ok:adv` | `err:adv` |
+
+ADR-0034 §1 already reserved the status field for "additional values …
+a non-breaking future extension"; this is that extension. The status
+parser splits the token on `:`: the base (`ok`/`err`) gives replayability
+(`status_is_ok` ⇔ base == `ok`), the `adv` suffix gives the mode — so an
+unknown future token degrades to "not ok, simple" rather than mis-parsing.
+
+### Journaling location: the dispatch layer, not the worker
+
+Both tags are written **at the dispatch layer**, where command + mode +
+outcome are all in scope — so the mode needs no plumbing into the worker:
+
+- **Success:** `spawn_dsl_dispatch`, immediately after
+ `execute_command_typed` returns `Ok`, calls
+ `append_history(source, submission_mode.is_advanced())` (best-effort).
+ `run_replay` does the same per replayed line (tagged simple — replay is
+ mode-agnostic), and the app-command sites (`perform_switch` /
+ `spawn_export` / `spawn_rebuild`) journal **simple** (`advanced = false`
+ — app commands run in any mode, so no `:` on recall; this also avoids a
+ redundant `: undo`).
+- **Failure:** unchanged location (the App→`JournalFailure`→runtime path,
+ already at the top), now carrying the mode — `JournalFailure` gains
+ `advanced`, and `DslFailed` gains `submission_mode` for the
+ worker-rejection sub-path (the parse-failure sub-path has it in
+ `dispatch_dsl`). `Ok`/`Err` are exclusive, so success-in-spawn and
+ failure-in-App-path never double-journal.
+
+The worker's `finalize_persistence` and the four no-op-skip / three
+read-only sites **no longer journal** — they leave the state writes
+(yaml/csv) in the worker transaction and let the dispatch layer journal
+the `Ok` outcome.
+
+### 4. Hydration reconstructs the `:`-prefixed form
+
+`read_recent_sources` parses each record's status tag and, for an
+advanced record, **reconstructs** the `: `-prefixed string from the
+canonical `source` (`format!(": {source}")`); simple records pass through
+bare. It still returns `Vec`, so `read_history_seed`,
+`seed_history`, and the `ProjectSwitched` payload are **unchanged**. A
+hydrated entry is therefore byte-identical to its in-session form, and
+recall behaves identically.
+
+### Back-compatibility
+
+Old `history.log` files have only `ok` / `err` tokens → parsed as
+`advanced = false` (simple). Their advanced commands stay un-`:`-able on
+recall — the pre-existing behaviour, not a regression; nothing migrates.
+`status_is_ok` keys off the base token, so `ok:adv` records replay
+exactly as `ok` does today (source is canonical either way).
+
+### Journal write is best-effort (amends ADR-0040)
+
+Because the journal is now written *after* the worker replies (i.e. after
+`tx.commit`), a journal-write failure can no longer roll the command back.
+It is **best-effort** — logged and ignored, exactly like the failure path
+already is (ADR-0034 §4) — so the two journal paths are finally
+consistent. State integrity is unchanged: yaml/csv/db still commit
+atomically in the worker (a *state*-write failure still rolls back and is
+fatal). The only property given up: on a rare journal-write failure (disk
+full) a committed command may be missing from `history.log` — not
+recallable/replayable next session, but the state is correct. User-chosen
+over keeping journaling coupled in the worker (which would have needed the
+~30-site mode plumbing). See the plan's §2 for the full trade-off.
+
+## Forks (user-chosen)
+
+- **Format = mode tag in the status field** (`ok:adv`/`err:adv`), over a
+ new 4th field (ambiguous with unescaped pipes in old `source`s without
+ a version bump) or a `:`-prefix in `source` (would make `source`
+ non-canonical and force replay to strip it).
+- **Scope = unified** (bug + feature) over bug-only: one mechanism does
+ both, and keeping `source` canonical for replay needs the mode tag
+ regardless, so bug-only is barely smaller and leaves the main ask open.
+- **Journaling location = dispatch layer, best-effort** over keeping it
+ worker-coupled-and-fatal (which needed the ~30-site mode plumbing). The
+ user's architectural call (§Status).
+
+## Consequences
+
+- Advanced history is reusable in simple mode; the `:` one-shot survives
+ resume. The in-memory and on-disk representations agree.
+- **Journaling left the worker.** `finalize_persistence` and the
+ no-op-skip / read-only sites no longer journal; success is journalled at
+ the dispatch layer (`spawn_dsl_dispatch` / `run_replay` / app-command
+ sites). The ring stays `Vec`; `seed_history` / `ProjectSwitched`
+ are untouched. The vestigial worker `source` plumbing has since been
+ **fully unwound** (2026-06-14 follow-up): `_source` removed from
+ `finalize_persistence` / `do_rebuild_from_text`; the three read-only
+ `*_request` wrappers inlined and deleted; and — because the cascade ran
+ deeper than first estimated — the now-dead `source` param dropped from
+ the ~30 worker handlers (leaf + composite) that only forwarded it, plus
+ the `source` field removed from the `DescribeTable` / `QueryData` /
+ `RunSelect` requests and the matching `DatabaseHandle` method parameters
+ (the ~164 call-site churn was mostly tests). The only `source` left in
+ the worker is the snapshot/undo label (`snapshot_then` /
+ `stage_pre_mutation` / `begin_batch`), passed at the match-arm level.
+ Purely mechanical, compiler-guided, no behaviour change.
+- **App commands recall bare.** Because they are dispatched outside the
+ `ExecuteDsl`/spawn path, app commands journal **simple** (`advanced =
+ false`) at their own sites, and `submit` excludes them from the ring's
+ `advanced` flag (`!is_app_command`) — so `mode advanced` / `undo` recall
+ bare and run fine in simple mode, with no redundant `:`.
+- **Journaling is now uniform (user-confirmed).** The spawn journals on
+ `outcome.is_ok()`, so **every** successful command is recorded — closing
+ a pre-existing gap where `show table` / `show data` / `select` journalled
+ but `show tables`/`show relationships`/`show indexes`, `show relationship
+ `, and `explain` did **not** (their worker arms carried no
+ `source` / no journal call). The new behaviour matches ADR-0034 §1
+ ("record every submitted command"); those reads are now recallable and
+ are re-run harmlessly on replay (`explain` never executes; shows produce
+ output, no state change). A DA finding, accepted as the more-correct
+ behaviour over re-adding command-outcome gating to preserve the old
+ inconsistency.
+- **Replay re-journaling.** When `replay` re-dispatches a line, the
+ re-written record is tagged from how replay dispatched (mode-agnostic →
+ `ok`), so a replayed advanced command may be re-journalled without
+ `:adv`. Replay correctness of execution is unchanged (it already parses
+ mode-agnostically); this only affects the *tag* of the re-written line.
+ Noted; not addressed here (replay's own mode-fidelity is out of scope).
+
+## Tests
+
+- **Tier-1 (`app.rs`):** an advanced one-shot / persistent-advanced
+ submission is stored `: `-prefixed; it recalls as `: …` in simple mode
+ and bare in advanced mode; a simple entry recalls bare in both; a bare
+ `:` is not stored; a parse-failure is still recallable; dedup/cap hold.
+- **Tier-1 (`history.rs`):** the writer emits `ok:adv`/`err:adv`;
+ `read_recent_sources` reconstructs the `: `-prefix for `:adv` records
+ and leaves `ok`/`err` records bare (so old logs read as simple);
+ `status_is_ok` is true for `ok` and `ok:adv`.
+- **Tier-3 (`iteration6_resume_history` / it):** the headline
+ **regression** — type a `:`-one-shot advanced command, journal +
+ hydrate, and assert it recalls **with** `:` in simple mode (fails on
+ current code); plus a persistent-advanced command round-tripping to a
+ `: …` recall.
+
+## Out of scope
+
+- Replay re-journaling mode-fidelity (above).
+- Special-casing app commands to avoid the redundant recall `: `.
+- Distinguishing one-shot from persistent advanced on recall (both are
+ simply "advanced" — the `:` is what simple mode needs either way).
+- A format version marker / pipe-escaping in `source` (unneeded — the
+ status-tag approach keeps `source` last and canonical).
diff --git a/docs/adr/0053-contextual-hint-command-and-keybinding.md b/docs/adr/0053-contextual-hint-command-and-keybinding.md
new file mode 100644
index 0000000..7bac135
--- /dev/null
+++ b/docs/adr/0053-contextual-hint-command-and-keybinding.md
@@ -0,0 +1,404 @@
+# ADR-0053: Contextual `hint` — F1 live-input keybinding + `hint` command, with a tier-3 teaching corpus (H2)
+
+## Status
+
+Accepted — implementation in progress. Revised after a `/runda` review
+(2026-06-14): corrected the verbosity-default fact; re-keyed tier-3
+content off `help_id`; split the pre-submit-diagnostic and runtime-error
+paths; added a comprehensiveness coverage test. Revised again during
+Phase B implementation (2026-06-15): the first exemplar showed per-*node*
+keying is too coarse for multi-form commands (`add`/`drop`/`show`/
+`create`), so D3 now keys tier-3 content **per form** via a
+`hint_ids: &[&str]` array mirroring `usage_ids` — and **clause-concept
+hints** are recorded as a deferred extension (separate tracking issue).
+The parallel question of whether the in-app `help` command should
+likewise distinguish advanced-SQL forms is tracked **separately** as
+Gitea issue #36 (it touches shipped, ADR-backed `help` behaviour).
+
+Decided in conversation 2026-06-14. Closes the last open piece of **A1**
+(the canonical app-command set, ADR-0003): every app command is
+implemented except `hint`, which ADR-0003's command table listed as
+*"Request a hint for the current input (ADR pending)."* This ADR is that
+pending decision. Tracked as **H2** in `docs/requirements.md`.
+
+References ADR-0003 (app-command set + the `:` escape), ADR-0019 (the
+friendly error layer / H1), ADR-0021 (per-command usage templates / H1a),
+ADR-0022 (ambient typing assistance — colour + hint panel + completion),
+ADR-0027 (input validity indicator), ADR-0046 (sidebar navigation +
+responsive input hint), ADR-0049 (input-field readline keymap), and
+ADR-0051 (context/state-aware keybinding strip).
+
+## Context
+
+`hint` is the only unbuilt app command. The naive reading — "show a hint" —
+hides a real subtlety, and a real cost.
+
+**The subtlety: a submitted `hint` command cannot see live input.** App
+commands are submitted with Enter, which empties the input buffer. By the
+time `hint` dispatches, the partial command it was meant to help with is
+gone. So "a hint for the current input" cannot be served by a submitted
+command alone — it needs a *keybinding* that acts on the live buffer
+without submitting. ADR-0003 said "current input"; `requirements.md`
+broadened it to "current input **or the most recent error**." Both are
+wanted; they map to two different trigger surfaces.
+
+**The cost: the value of `hint` is content, not plumbing.** The app
+already carries two tiers of contextual text:
+
+- **Tier 1** — terse, always-on: syntax colour (ADR-0022); the error
+ *headline* alone (ADR-0019, when `messages_verbosity: Short`).
+- **Tier 2** — short contextual lines: the ambient typing prose /
+ `expected` set, shown live while typing (ADR-0022, catalogue
+ `hint.ambient_*` / `hint.value_slot_*`); and the error `hint:` field —
+ which, because `Verbosity::Verbose` is the **default**
+ (`src/friendly/translate.rs:46`), is shown **by default** beneath every
+ error headline (`messages short` is the opt-*out*, not `messages
+ verbose` the opt-in).
+
+So the verbose error hint is **already on screen by default**. If `hint`
+merely re-showed it, it would duplicate what the user can already see (and
+the ambient panel). To justify itself, `hint` must add a **tier 3**: a
+genuinely deeper, *teaching*-grade explanation — what the command/error
+means, a worked example, and the underlying relational concept. That
+corpus does not exist yet, and
+authoring it (to the standard of a teaching tool, where "pedagogy wins
+ties") is the bulk of the work.
+
+The mechanism is small and reuses everything already present: the command
+REGISTRY (`src/dsl/grammar/mod.rs`), the `AppCommand` enum
+(`src/dsl/command.rs`), key dispatch (`App::handle_key`,
+`src/app.rs:1155`), the `note_help`/`note_help_topic` renderers
+(`src/app.rs:2982`/`3021`), the parser/walker expected-set
+(`ParseError.expected`, `WalkResult.tail_expected`), the friendly
+catalogue + `t!` macro + `keys.rs` validation, and the output styling
+vocabulary (`OutputStyleClass::Hint`).
+
+## Decision
+
+### D1 — Two surfaces, no topic argument
+
+`hint` is delivered through **two complementary surfaces**:
+
+1. **F1 keybinding → live input.** Pressing **F1** while typing renders a
+ tier-3 hint for the command currently in the buffer, into the output
+ panel, **without submitting or altering the buffer**. This is the
+ primary, most-valuable path (it serves the literal "current input").
+2. **`hint` command → most recent error.** Submitting `hint` renders the
+ tier-3 expansion of the most recent error. This is why the command
+ exists despite the empty-buffer problem: the thing it helps with is
+ the *last thing you tried*, not the now-empty buffer.
+
+`hint` takes **no topic argument**. Explicit per-command reference is
+already `help ` (H3); `hint` is purely *contextual*, which keeps
+the two cleanly distinct (`hint` = "help me with what I'm doing right
+now"; `help insert` = "show me the insert reference").
+
+F1 is a **read-only overlay**: it never alters the input buffer, the
+cursor, or the live completion memo (ADR-0022) — it only emits a block
+into the output journal. (It must therefore be handled in `handle_key`
+*before* the "any other key clears the memo" fall-through.)
+
+### D2 — Trigger matrix
+
+| Trigger | Buffer / state | Result |
+|---|---|---|
+| **F1** | non-empty input | tier-3 hint for the command being typed, plus the live "expected next" (from the walker's `tail_expected` / parser `expected`) |
+| **F1** | empty input, a recent error exists | tier-3 expansion of that error |
+| **F1** | empty input, no recent error | a short "getting started" pointer (press F1 while typing a command; `help` for the full list) |
+| **`hint`** (submitted) | a recent error exists | tier-3 expansion of that error (primary use) |
+| **`hint`** (submitted) | no recent error | the same "getting started" pointer |
+
+F1 is inert behind a modal and while a sidebar panel holds navigation
+focus (consistent with the existing `handle_key` gates, ADR-0046); it is
+active in the input context in both Simple and Advanced mode.
+
+**Two error sources, one namespace.** Errors come in two kinds and reach
+`hint` by different routes:
+
+- **Pre-submit diagnostics** (the ~33 `diagnostic.*` classes — arity,
+ type, unknown table/column) are computed *while typing* by the walker.
+ The **F1 live-input path** reads the current under-cursor diagnostic
+ directly from the walker (the same source the ambient panel uses) and
+ renders its `hint.err.` block — no stored state needed.
+- **Runtime errors** (the 9 `translate_error` classes) occur *after*
+ submit. The **`hint` command / empty-input F1** path reads them via the
+ stored `last_error_hint_key` (D5).
+
+Both render from the same `hint.err.*` namespace. **`:`-prefix handling:**
+on the simple-mode one-shot escape (`: SELECT …`), command
+identification for the F1 path strips the leading `:` first, so the
+advanced form is matched.
+
+### D3 — The tier-3 content model
+
+Tier-3 blocks live in the friendly catalogue under the existing `hint:`
+top-level namespace (where tier-2 ambient strings already live), in two
+new sub-namespaces:
+
+- **`hint.cmd.`** — one per command **form**, keyed by a **new
+ `hint_ids: &'static [&'static str]`** field on `CommandNode`
+ (`src/dsl/grammar/mod.rs:512`), **mirroring the existing `usage_ids`**.
+ The F1 live-input path resolves the current input to its form's hint key
+ via `hint_key_for_input_in_mode`, which reuses the same form-word
+ disambiguation as `usage_key_for_input_in_mode`.
+
+ **Why an array mirroring `usage_ids`, not a per-node `hint_id`**
+ *(`/runda`/implementation revision, 2026-06-15)*: a single per-node key
+ is too coarse. Several entry words are **one node spanning many forms** —
+ `add` (column/relationship/index/constraint), `drop` (table/column/
+ relationship/index), `show` (data/table/tables/relationships/indexes),
+ `create` (table/index). A live-input hint for `add 1:n relationship` is
+ only useful if it is *specific to relationships*, so the content must be
+ **per form**, not per node. The project already solved exactly this for
+ usage templates (`usage_ids` is a per-form array, disambiguated by the
+ form word), so `hint_ids` mirrors it. Single-form nodes carry one entry;
+ multi-form nodes carry one per form. This also covers the advanced-SQL
+ forms whose `usage_ids` are empty (`SQL_INSERT/UPDATE/DELETE`,
+ `EXPLAIN_SQL`) — they get their own `hint_ids` directly, independent of
+ usage, with mode-correct SQL examples. (The `help`-list collapse of
+ advanced-SQL forms is a separate gap — issue #36.)
+
+ **Deferred extension — clause-concept hints** (issue #37): per-form is
+ the right granularity for tier-3 *teaching* (position-awareness within a
+ form is owned by tier-2 ambient + the live `Next:` line, D4). But some
+ **concepts live inside a clause**, not a form — `… on delete ⟨cascade|
+ set null|restrict⟩` (referential actions), the `create table` constraint
+ slots (`primary`/`unique`/`check`/`foreign`), `with pk`, `1:n`/`m:n`
+ cardinality. A learner parked in such a clause may want teaching deeper
+ than tier-2's candidate list but narrower than the whole-form block. v1
+ does **not** build this (it would multiply content for points whose value
+ we can't yet measure, and we don't expect to accumulate usage statistics
+ to drive it empirically — it will be tackled as a deliberate follow-up
+ job). The keying does not lock it out: a later `hint.concept.`
+ namespace can be surfaced when the cursor sits in a recognized clause,
+ layered on top of the per-form block.
+- **`hint.err.`** — one per error/diagnostic class, keyed by the
+ friendly error/diagnostic key (e.g. `hint.err.foreign_key.child_side`,
+ `hint.err.type_mismatch`, `hint.err.insert_arity_mismatch`). Used by
+ both error routes (D2).
+
+Each tier-3 block is a **structured entry with three labelled parts**, so
+the voice stays consistent and the renderer can style them uniformly:
+
+```yaml
+hint.cmd.dsl.insert:
+ what: "Add one or more rows to a table."
+ example: "insert into Customers values ('Ann', 'ann@x.io')"
+ concept: "A row is one record; each value lines up with a column, in
+ order. Columns typed `serial`/`shortid` fill themselves — leave them out."
+```
+
+- **`what`** — one or two plain sentences: what this command does / what
+ this error means.
+- **`example`** — a single concrete, copyable line (rendered neutral, not
+ muted, so it stands out as runnable).
+- **`concept`** — the underlying relational idea, in teaching voice; the
+ part that makes this tier-3 rather than tier-2.
+
+`concept` is optional where there is genuinely no concept beyond the
+mechanics (e.g. `quit`); `what` + `example` are always present.
+
+### D4 — Rendering
+
+Both surfaces render through one new renderer, `App::note_hint*` (sibling
+of `note_help`/`note_help_topic`, `src/app.rs`), emitting a small framed
+block into the `output` buffer as `OutputKind::System` with
+`OutputStyleClass::Hint` on the `what`/`concept` prose and `Neutral` on
+the `example` line. The block is **persistent** (scrolls in the journal),
+unlike the transient ambient panel — pressing F1 is an explicit request
+to *keep* the deeper guidance on screen. The bottom keybinding strip
+(ADR-0051) advertises F1 in the editing/typing state.
+
+### D5 — "Most recent (runtime) error" state
+
+The **runtime-error route** (submitted `hint`, and empty-input F1) needs
+to map the last runtime error back to its `hint.err.` key. Runtime
+errors today live only as rendered text in the `output` buffer. We add a
+single small piece of `App` state — **`last_error_hint_key:
+Option`** — set at the `translate_error` call sites
+(`runtime.rs:2615`, `app.rs:2424`) when a friendly error is rendered,
+cleared when a later command succeeds. Absent → the "getting started"
+pointer.
+
+The **pre-submit-diagnostic route** (the F1 live-input path) needs no
+stored state: it reads the current diagnostic from the walker at F1 time
+(D2). This is the cleaner split the `/runda` pass surfaced — typing-time
+diagnostics and post-submit runtime errors are genuinely different
+sources and should not be funnelled through one stored key.
+
+### D6 — Content scope: comprehensive for v1
+
+v1 ships tier-3 content for the **whole inventory**, not a subset (the
+graceful tier-2 fallback below is a safety net, not the plan):
+
+- **~37 command forms** — every distinct node in `REGISTRY` gets its own
+ `hint.cmd.` block (app + DSL + DDL + advanced-mode SQL forms),
+ each with a **mode-correct example** (the advanced-SQL forms show SQL
+ syntax, their simple siblings show DSL — no sharing).
+- **9 runtime error classes** — `unique`, `foreign_key` (×4 sides),
+ `not_null`, `check`, `type_mismatch`, `not_found`, `already_exists`,
+ `generic`, `invalid_value` — each gets a `hint.err.*` block.
+- **~33 `diagnostic.*` pre-submit classes** — arity, type, unknown
+ table/column, etc. — each gets a `hint.err.*` block.
+
+The full enumerated checklist is the implementation plan's tracking
+artifact (see *Content inventory*, below).
+
+**Fallback (safety net):** if a tier-3 key is ever missing at runtime,
+the surface degrades to tier 2 — the ambient prose for the command path,
+or the verbose error `hint:` for the error path — never to a blank or an
+error. The `keys.rs` build-time validation keeps the corpus honest, so a
+missing key is caught in tests, not in front of a student.
+
+### D7 — Authoring process: exemplars-first
+
+Because the corpus is large and its *voice* is a pedagogical decision the
+maintainer owns, content is produced in two stages:
+
+1. This ADR carries **2–3 worked exemplars** (below) as the canonical
+ style reference. The `/runda` review of this ADR is where the voice and
+ depth are approved.
+2. Once approved, the remaining blocks are authored to that template in
+ **reviewable batches** (grouped by area: DDL, DML, app commands,
+ error classes), not one monolithic drop.
+
+### Exemplars (the style reference to approve)
+
+**Command (F1 live-input), `insert`:**
+
+```
+Hint — insert
+ What: Add one or more rows to a table.
+ Example: insert into Customers values ('Ann', 'ann@x.io')
+ Concept: A row is one record; each value lines up with a column, in
+ order. Columns typed serial/shortid fill themselves — leave
+ them out.
+ Next: a value list `(...)`, or `(col, ...) values (...)` to name columns
+```
+(The "Next:" line is the live expected-set from the walker, shown only on
+the non-empty-input F1 path.)
+
+**Error (`hint` command), foreign-key child-side violation:**
+
+```
+Hint — no parent row to point at
+ What: The value you inserted into Orders.customer_id doesn't match
+ any Customers row, so the foreign key has nothing to point at.
+ Example: First insert into Customers values ('Ann', ...)
+ Then insert into Orders values (..., 'Ann')
+ Concept: A foreign key is a promise that every child points at a real
+ parent. The parent must exist first. To allow orphans on
+ delete instead, set the relationship's `on delete` to
+ `set null` or `cascade`.
+```
+
+**Command (F1 live-input), `add 1:n relationship`:**
+
+```
+Hint — add relationship
+ What: Link two tables so a parent row can own many child rows.
+ Example: add 1:n relationship from Customers.id to Orders.customer_id
+ Concept: The "1:n" means one parent, many children. The child column
+ holds the foreign key; `--create-fk` adds it for you if it
+ doesn't exist yet.
+```
+
+## Forks (all user-chosen, 2026-06-14)
+
+- **Trigger model:** both a keybinding (live input) and a submitted
+ command (last error), rather than command-only or keybinding-only — the
+ live-input path is the most useful, but the command completes the A1
+ slot and serves the error case.
+- **Keybinding = F1:** the universal help convention; the key is
+ genuinely free (no `KeyCode::F(1)` binding exists today — the `"F1"`
+ strings in `input_render.rs`/tests are scenario labels, not the key, and
+ ADR-0022 uses no `F1` requirement label). No collision with the ADR-0049
+ readline keys, `Ctrl-O` (ADR-0046), `Esc`-clear, or the reserved
+ `Ctrl-C` cancel (I5). Rejected: `?` (a typeable character — fiddly
+ position-dependent handling) and a Ctrl/Alt chord (less discoverable, no
+ advantage).
+- **No topic argument:** contextual only; `help ` already owns
+ explicit reference lookup.
+- **Comprehensive content for v1:** the full inventory, not a starter
+ subset.
+- **Exemplars-first authoring:** lock the voice on a few blocks, then
+ mass-author to template.
+
+## Consequences
+
+- **A1 closes.** With `hint` registered and built, all 15 canonical
+ app-level commands exist in both modes.
+- **A third contextual tier exists.** Students get on-demand, teaching-
+ grade guidance that is deeper than the always-on colour, the headline,
+ the ambient one-liner, and the verbose error hint — without cluttering
+ those terse defaults.
+- **One new keybinding (F1)** joins the keymap and the ADR-0051 strip.
+- **A new `hint_ids: &[&str]` field on `CommandNode`** (mirroring
+ `usage_ids`) + a `hint_key_for_input_in_mode` lookup (reusing the
+ `usage_key_for_input_in_mode` form-disambiguation), one new field of
+ `App` state (`last_error_hint_key`), and one new renderer family
+ (`note_hint*`); the `AppCommand` enum gains `Hint`, the grammar a `HINT`
+ node, the REGISTRY one entry.
+- **A large, durable content corpus** (~37 command blocks + ~42 error/
+ diagnostic blocks ≈ 80) enters the catalogue under `hint.cmd.*` /
+ `hint.err.*`, validated by `keys.rs`. This is ongoing surface area: new
+ commands/error classes should ship with their tier-3 hint (a checklist
+ item for future feature ADRs).
+- **Testing:** Tier-1 unit tests for the trigger matrix (F1 with
+ empty/non-empty input; `hint` with/without a recent error;
+ `last_error_hint_key` set on the `translate_error` sites and cleared on
+ success; the pre-submit-diagnostic vs runtime-error routing; the `:`
+ strip), the command-identification logic, and the tier-2 fallback;
+ Tier-2 `insta` snapshots for a representative rendered hint block;
+ Tier-3 integration tests for the end-to-end flows (type a partial
+ command → F1 → block appears, **buffer and completion memo untouched**;
+ run a failing command → `hint` → error expansion). **A
+ comprehensiveness coverage test** (enforces D6): iterate the REGISTRY
+ and assert every node has a `hint_id` resolving to a `hint.cmd.*` block,
+ and every runtime-error/diagnostic class has a `hint.err.*` block —
+ `keys.rs` only checks that *referenced* keys resolve, not that every
+ command/error *has* one, so this test is what makes "comprehensive"
+ enforceable rather than aspirational.
+
+## Out of scope
+
+- **Per-topic `hint `** — OOS (rejected): `help ` already
+ serves explicit lookup; a topic arg would overlap it and double the
+ content-authoring surface.
+- **Re-showing tier-3 inline as the always-on ambient hint** — OOS
+ (rejected): the ambient panel stays terse by design (ADR-0022); tier-3
+ is on-demand. Promoting it would defeat the tiering.
+- **Localised tier-3 content beyond `en-US`** — OOS (deferred): the
+ catalogue is structured for i18n (ADR-0019), but additional locales
+ follow the project's English-only-for-v1 stance (requirements X2).
+- **`hint` for a *successful* command's deeper teaching** (e.g. "you just
+ created a table — here's what an index would add") — OOS (deferred): a
+ plausible future tier-3 use, but v1 scopes the command path to errors
+ and the F1 path to in-progress input.
+- **Clause-concept hints** (`… on delete ⟨action⟩`, constraint slots,
+ `with pk`, cardinality) — OOS (deferred, issue #37): a
+ `hint.concept.` layer surfaced when the cursor sits in a
+ recognized clause, deeper than tier-2's candidate list but narrower than
+ the per-form block. Per-form keying (D3) does not lock it out. To be
+ tackled as a deliberate follow-up job, not gated on usage statistics.
+
+## Content inventory (implementation tracking)
+
+The implementation plan enumerates and checks off every block:
+
+- **`hint.cmd.`** — one per distinct `REGISTRY` node (~37), each
+ with its own `hint_id` and a mode-correct example: app (`save`, `save
+ as`, `load`, `new`, `rebuild`, `export`, `import`, `replay`, `undo`,
+ `redo`, `mode`, `messages`, `copy`, `help`, `hint`, `quit`); DDL
+ (`create table`, `create m:n`, `add column`/`relationship`/`index`,
+ `drop`, `rename`, `change column`); DML (`insert`, `update`, `delete`,
+ `show`, `seed`, `explain`, `select`/`with`). The **7 advanced-mode SQL
+ forms** (`SQL CREATE TABLE`, `ALTER TABLE`, `CREATE/DROP INDEX`, `DROP
+ TABLE`, `SQL INSERT/UPDATE/DELETE`, `EXPLAIN SQL`, raw `SELECT`/`WITH`)
+ each get their **own** block with SQL syntax — they do **not** reuse
+ their simple sibling's (this is the `/runda` correction; the parallel
+ `help`-side gap is issue #36).
+- **`hint.err.*`** — one per runtime error class (`unique`,
+ `foreign_key.{child,parent}_side`, `not_null`, `check`,
+ `type_mismatch`, `not_found`, `already_exists`, `generic`,
+ `invalid_value`) and per `diagnostic.*` pre-submit class.
diff --git a/docs/adr/README.md b/docs/adr/README.md
index 2e15522..aaa1e8a 100644
--- a/docs/adr/README.md
+++ b/docs/adr/README.md
@@ -27,7 +27,7 @@ This directory contains the project's ADRs, recorded per
- [ADR-0019 — Friendly error layer (H1) and i18n message catalog](0019-friendly-error-layer-and-i18n.md)
- [ADR-0020 — Tokenization layer for the DSL parser](0020-tokenization-layer-for-the-dsl-parser.md) — **Superseded by ADR-0024 (never implemented).** Specified a `chumsky`-over-tokens architecture (separate lexer, `define_keywords!`, `&[Token]` grammar). ADR-0024 adopted a scannerless hand-rolled walker and removed `chumsky` entirely; the lexer/keyword/token model here does not exist. Kept as institutional memory of the path not taken.
- [ADR-0021 — Parser-as-source-of-truth for H1a (per-command usage in parse errors)](0021-parser-as-source-of-truth-for-h1a.md) — **Mechanism superseded by ADR-0024; H1a scope continued in ADR-0042.** The *intent* (show the command's grammar at the point of error) shipped — `usage_ids` on each `CommandNode`, the `parse.usage.*` templates, and the `available_commands` fallback all exist — but via grammar nodes, not the `chumsky` `UsageEntry` registry / `parse.token.*` keys described here (which were never built).
-- [ADR-0022 — Ambient typing assistance: colour, hint panel, completion (I3 + I4)](0022-ambient-typing-assistance.md) — **Amendment 1 supersedes §12's simple-mode-only carve-out**: the unified mode-aware walker (ADR-0030/0031/0032) now speaks SQL, so advanced-mode ambient assistance is re-enabled. `ambient_hint_in_mode` + `hint_resolution_at_input_in_mode` + `expected_for_hint_snapshot` thread `Mode`; `render_hint_panel` calls ambient for all modes (no more advanced-mode `None`); the one-shot `:` sigil is stripped before the ambient walk. Fixes a live bug where advanced-mode SQL hinting/completion-preview were dead despite Phase 2 marking them green (validated at the engine layer, not the UI). Simple-mode gating, highlighting, and the §13 performance posture are unchanged; covered by an app-level render test plus ambient-layer regression locks; **Amendment 2 reverses the handoff-14 keywords-first candidate ordering** — schema identifiers (table/column/relationship names) now sort *before* keywords so a name the user would have to look up stays visible in the single-row, window-scrolled candidate line (keywords are learned over time; the `tok_identifier`/`tok_keyword` colour split marks the boundary); shipped with a `walk_repeated` fix that surfaces a list item's trailing optionals at a clean boundary (`order by Name ` → `asc`/`desc`, `select Name ` → `as`, `create table … Code(text) ` → `not`/`unique`/`default`/`check`; the `,` separator deliberately not surfaced); records a deferred two-line hint box for growing lists; **Amendment 3 makes the ambient-hint fallback rung schema-aware** — Amendment 1's bottom-rung `parse_command_in_mode` was schemaless while every earlier rung was not, so between-values insert hints pointed at `)` (type-blind close) instead of `,` and wrong-arity closed tuples read "submit with Enter" for an input the schema-aware parse rejects (issue #2); now uses `parse_command_with_schema_in_mode`, no extra walk, with the friendly arity diagnostic still winning at its higher rung; **Amendment 4 gives column types a dedicated highlight class** — both `Node::Ident.highlight_override` *and* the `Word.highlight_override` field were dead (driver destructured the former to `_`, `walk_word` hardcoded `Keyword`); now both wired through, with a new `HighlightClass::Type` + eighth `Theme` field `tok_type` (a pink/deep-magenta distinct from both keyword purple and identifier teal) so types no longer render identically to identifiers (issue #8); the three `IdentSource::Types` slots opt in via `Some(Type)` (advanced-mode single-word SQL aliases — `float`, `varchar`, … per ADR-0035 §3 — ride along for free), and the two-word `double precision` alias opts in via the new `Word::type_keyword` constructor so it matches its synonyms; **Amendment 5 lets the hint panel grow for long prose hints** — a fixed one-row panel clipped long field-value/usage hints past the first line (issue #12); `resolve_hint_lines` now pre-wraps prose and `render_right_column` sizes the panel to the line count (1 row default, up to `MAX_HINT_ROWS`=3, reclaimed when short) with a `clamp_wrapped` ellipsis backstop; the candidate list still scrolls horizontally on one row (Amendment 2's deferred two-line candidate box stays deferred); also shortens the 299-char `parse.usage.sql_create_table` synopsis to a terse one-liner (full grammar remains in `help.ddl.sql_create_table`); **Amendment 6 adds a curated SQL function-name list** (`src/dsl/sql_functions.rs`, `KNOWN_SQL_FUNCTIONS` — aggregates + common + broader scalars; `cast` deliberately excluded as its `CAST(x AS type)` syntax isn't a plain-call shape) as the single source of truth shared by two consumers at the `sql_expr_ident` slot (ADR-0031 §1): **issue #15** offers the functions as Tab candidates under a new `CandidateKind::Function` + ninth `Theme` colour `tok_function` (a blue distinct from keyword/identifier/type, parallel to Amendment 4's `tok_type`) so a learner discovers `sum`/`upper`/…; **issue #16** restores the typing-time column-typo flag the issue-#6 fix had dropped wholesale at this slot — `invalid_ident_at_cursor` now bails only when the partial prefix-matches a known function, else falls through to the schema-column check, so `select Agx` warns again at typing time while `select sum` does not (the issue-#6 lockdown tests + the submit-time `unknown_column` diagnostic path are untouched, and the no-validation-allowlist posture stands); see ADR-0031's status note for the grammar-side anchor
+- [ADR-0022 — Ambient typing assistance: colour, hint panel, completion (I3 + I4)](0022-ambient-typing-assistance.md) — **Amendment 1 supersedes §12's simple-mode-only carve-out**: the unified mode-aware walker (ADR-0030/0031/0032) now speaks SQL, so advanced-mode ambient assistance is re-enabled. `ambient_hint_in_mode` + `hint_resolution_at_input_in_mode` + `expected_for_hint_snapshot` thread `Mode`; `render_hint_panel` calls ambient for all modes (no more advanced-mode `None`); the one-shot `:` sigil is stripped before the ambient walk. Fixes a live bug where advanced-mode SQL hinting/completion-preview were dead despite Phase 2 marking them green (validated at the engine layer, not the UI). Simple-mode gating, highlighting, and the §13 performance posture are unchanged; covered by an app-level render test plus ambient-layer regression locks; **Amendment 2 reverses the handoff-14 keywords-first candidate ordering** — schema identifiers (table/column/relationship names) now sort *before* keywords so a name the user would have to look up stays visible in the single-row, window-scrolled candidate line (keywords are learned over time; the `tok_identifier`/`tok_keyword` colour split marks the boundary); shipped with a `walk_repeated` fix that surfaces a list item's trailing optionals at a clean boundary (`order by Name ` → `asc`/`desc`, `select Name ` → `as`, `create table … Code(text) ` → `not`/`unique`/`default`/`check`; the `,` separator deliberately not surfaced); records a deferred two-line hint box for growing lists; **Amendment 3 makes the ambient-hint fallback rung schema-aware** — Amendment 1's bottom-rung `parse_command_in_mode` was schemaless while every earlier rung was not, so between-values insert hints pointed at `)` (type-blind close) instead of `,` and wrong-arity closed tuples read "submit with Enter" for an input the schema-aware parse rejects (issue #2); now uses `parse_command_with_schema_in_mode`, no extra walk, with the friendly arity diagnostic still winning at its higher rung; **Amendment 4 gives column types a dedicated highlight class** — both `Node::Ident.highlight_override` *and* the `Word.highlight_override` field were dead (driver destructured the former to `_`, `walk_word` hardcoded `Keyword`); now both wired through, with a new `HighlightClass::Type` + eighth `Theme` field `tok_type` (a pink/deep-magenta distinct from both keyword purple and identifier teal) so types no longer render identically to identifiers (issue #8); the three `IdentSource::Types` slots opt in via `Some(Type)` (advanced-mode single-word SQL aliases — `float`, `varchar`, … per ADR-0035 §3 — ride along for free), and the two-word `double precision` alias opts in via the new `Word::type_keyword` constructor so it matches its synonyms; **Amendment 5 lets the hint panel grow for long prose hints** — a fixed one-row panel clipped long field-value/usage hints past the first line (issue #12); `resolve_hint_lines` now pre-wraps prose and `render_right_column` sizes the panel to the line count (1 row default, up to `MAX_HINT_ROWS`=3, reclaimed when short) with a `clamp_wrapped` ellipsis backstop; the candidate list still scrolls horizontally on one row (Amendment 2's deferred two-line candidate box stays deferred); also shortens the 299-char `parse.usage.sql_create_table` synopsis to a terse one-liner (full grammar remains in `help.ddl.sql_create_table`); **Amendment 6 adds a curated SQL function-name list** (`src/dsl/sql_functions.rs`, `KNOWN_SQL_FUNCTIONS` — aggregates + common + broader scalars; `cast` deliberately excluded as its `CAST(x AS type)` syntax isn't a plain-call shape) as the single source of truth shared by two consumers at the `sql_expr_ident` slot (ADR-0031 §1): **issue #15** offers the functions as Tab candidates under a new `CandidateKind::Function` + ninth `Theme` colour `tok_function` (a blue distinct from keyword/identifier/type, parallel to Amendment 4's `tok_type`) so a learner discovers `sum`/`upper`/…; **issue #16** restores the typing-time column-typo flag the issue-#6 fix had dropped wholesale at this slot — `invalid_ident_at_cursor` now bails only when the partial prefix-matches a known function, else falls through to the schema-column check, so `select Agx` warns again at typing time while `select sum` does not (the issue-#6 lockdown tests + the submit-time `unknown_column` diagnostic path are untouched, and the no-validation-allowlist posture stands); see ADR-0031's status note for the grammar-side anchor; **Amendment 7 surfaces optional positional args in the hint panel** (issue #26): at `seed
▮` the optional row count (a bare `NumberLit` with no candidate) was invisible next to the `set`/`--seed` chips, and the resolver short-circuits on the already-complete command. Extends the issue-#4 `IntroProse` `HintMode` (ADR-0024 §HintMode-per-node) to survive trailing optionals: `walk_optional` stashes a skipped inner's `IntroProse` key into a new `WalkContext.surviving_intro_hint` (key + position) before the empty match clears `pending_hint_mode`, and the snapshot keeps it only when the skip position is the cursor (so it never leaks past a later-consumed `set …` clause or once the count is given); the resolver returns it ahead of the empty-expected short-circuit. The seed count is wrapped `Hinted{IntroProse("hint.seed_count")}`; prose names the count (default 20), the `.column` column-fill form, and `set`/`--seed` (user-chosen scope). Only `IntroProse` is carried; `ProseOnly`/`ForceProse` and the CREATE-TABLE element (a required `Repeated`) are untouched; no `AmbientHint`/renderer change
- [ADR-0023 — Unified declarative grammar tree](0023-unified-grammar-tree.md) — direction (superseded for execution detail by ADR-0024)
- [ADR-0024 — Unified grammar tree: execution plan](0024-unified-grammar-tree-execution-plan.md) — **Accepted**, the executable spec — implemented (Phases A–F; Phase F shipped "minimal", `parser.rs` retained as the router — see the ADR's Phase F implementation note)
- [ADR-0025 — Indexes](0025-indexes.md) — **Accepted** (**Amendment 1, 2026-05-25**: UNIQUE indexes admitted on the **advanced-mode** surface via `CREATE UNIQUE INDEX` — ADR-0035 §4d; the `IndexSchema.unique` flag round-trips through `project.yaml` with no new metadata table since the engine reports uniqueness natively; simple-mode `add unique index` stays deferred), `add index` / `drop index`, persistence, rebuild-table preservation, and items-list display (`C3` index portion + `S2`)
@@ -51,6 +51,11 @@ This directory contains the project's ADRs, recorded per
- [ADR-0043 — Compound-primary-key foreign-key references (T3)](0043-compound-pk-foreign-key-references.md) — **Accepted + implemented 2026-06-09** (all four forks confirmed at the recommended option: full-PK matching, house-style uniform lists, parenthesized DSL syntax, bare-SQL-FK auto-expansion). Closes `requirements.md` **T3** `[x]` — the relationship model went list-based across six layers (single-column preserved, no migration), DSL `from P.(a,b) to C.(x,y)` + SQL `FOREIGN KEY (a,b) REFERENCES P(x,y)` parse/execute/enforce, 12 tests in `tests/it/compound_fk.rs`. Closes the open leg of `requirements.md` **T3**: a foreign key that *references* a parent's compound primary key. A 2026-06-09 audit found single-column FK woven through ~15–20 sites (metadata table, `RelationshipSchema`, `project.yaml` `RawEndpoint`, both grammar surfaces, executor FK-DDL emission, per-column type-compat, display) — earns an ADR, not an inline build. **Decision:** reference the parent's **full** compound PK, matched **positionally** to an equal-length child column list, per-pair `fk_target_type` compat (ADR-0011, element-wise); DSL `from
.(a, b) to .(x, y)` (single form unchanged), SQL `FOREIGN KEY (x, y) REFERENCES P(a, b)` (extend the existing one-cap lists; bare table-level FK auto-expands to the parent PK when arities match). **Storage — no migration (back-compat not required, user-confirmed 2026-06-09; no installed base):** the relationship endpoint joins the list convention `project.yaml` *already* uses — `columns: [a, b]` like `primary_key: [id]` and index `columns: [...]` (the endpoint was the lone scalar `column:` holdout); the metadata `TEXT` columns are unchanged and store the list **comma-joined** (`a,b`; the bare name for single — safe because identifiers are `[A-Za-z0-9_]+`). No F3 migrator, no version bump; accepted trade-off is that a pre-change `project.yaml` with relationships won't load (clean cutover). In-memory model goes list-based (`Vec`) through all six layers; the enforced FK is the rebuilt child-table DDL (`FOREIGN KEY (a,b) REFERENCES P(x,y)`), one relationship = one undo step (ADR-0013). Genuine forks escalated: matching policy (full-PK vs subset), storage (house-style uniform lists vs normalized table), DSL syntax (parenthesized vs repeated-dotted), bare-SQL-FK auto-expansion. OOS: subset/non-PK (UNIQUE-targeted) FK references; any single-column behaviour change
- [ADR-0044 — Relationship visualization (two-table connector diagrams)](0044-relationship-visualization.md) — **Accepted 2026-06-09; implemented 2026-06-10** (closes `requirements.md` V1; second `/runda` pass over the implementation; §3 last-resort helper line considered and rejected). Resolves **ADR-0016 OOS-1** and closes the open half of `requirements.md` **V1** ("a selected relationship as two tables joined by a line"). Renders a relationship as **Style A** (two structure boxes + connector). **Reach = "relationship-relevant"** (user-chosen over global / show-only): diagrams on the surfaces where the relationship is the *subject* — `show relationship ` (one full diagram), `show table ` (T's structure box then a **Relationships** section of **stacked compact** per-relationship diagrams — chosen over a focal-centred subgraph: no crossing lines, scales via scroll, two-boxes-wide fits any terminal), and relationship DDL echoes (`add`/`drop`/`modify relationship`); incidental DDL echoes (`add column`, `drop index`, `change column`, plain `create table`) keep the terse prose, via a `Diagram`|`Prose` render mode on `render_structure`. Reading convention **child(FK)-left / parent-right, arrow →, `n`…`1` cardinality**, applied uniformly; every box gets a **bold title row + rule** so the name can't read as a column. **Compound FKs** (ADR-0043) route one connector per positional pair + an explicit pairing line. **Width-aware** (first in the codebase) but **App-side**: `render_structure`/diagram rendering runs in `app.rs` (the worker only returns `TableDescription`s), a new `App::last_output_width` (set from `ui.rs`) drives side-by-side vs a **vertical-stack** fallback + last-resort "run `show relationship`" pointer; rendered once at command time, **no live reflow** (V4). `show relationship`'s worker path (`do_show_one`, prose-only) is restructured to return both endpoint `TableDescription`s. Styling reuses **ADR-0028** App-side styled runs (new classes: table-name/key/connector/cardinality/action) — no worker→UI contract change. **Partially supersedes ADR-0016 §5** (prose block replaced on relationship-subject surfaces, retained on incidental ones); extends §4 (layout width-awareness, still no cell truncation) and §6 (per-span theming). Tests: insta snapshots (single, compound, vertical fallback, helper line, self-referential, multi-rel `show table`) + width-threshold/routing unit tests + Tier-3 wiring; enumerated prose-fallout updates (`output_render.rs:121/135/793`, the relationships snapshot, `walking_skeleton.rs:477/530`). A `/runda` DA pass corrected three inverted-architecture claims (App-side rendering, untracked width, prose-in-worker show-relationship) before acceptance. OOS: user-configurable display setting (OOS-7), live reflow (V4), whole-DB ER export (V3), m:n (C4), ASCII fallback (ADR-0016 OOS-5)
- [ADR-0045 — `create m:n relationship` convenience command (C4)](0045-mn-convenience.md) — **Accepted + implemented 2026-06-10** (closes `requirements.md` **C4**; all forks user-confirmed + a `/runda` DA pass that verified the `do_create_table` reuse against code and corrected the "no PK-less tables" assumption — advanced SQL `create table t (a int)` has none, so a parent-PK guard is retained). Implementation corrected a second ADR premise: "the walker already dispatches multiple nodes per entry word" held only in *advanced* mode — two simple-mode spots (dispatcher `decide`, completion continuation-merge) assumed ≤1 DSL form per entry word and were generalized **behaviour-preservingly** (dispatch reduces to the old single-candidate commit; completion merge gated on `simple_count > 1`). Junction echo wired (`render_create_m2n`, round-trips as SQL). `create m:n relationship from to [as ]` generates a junction table with one FK column per parent PK column, a **compound PK over all the FK columns** (the textbook junction — the pair is unique, no duplicate links), and **two 1:n relationships**, all in **one transaction = one undo step** (built by reusing `do_create_table`, which already takes `foreign_keys` + writes relationship metadata — no batch bracketing). Forks all user-chosen: junction PK = compound-over-FKs (vs surrogate serial / no PK); referential actions = **`CASCADE`** on delete+update (vs NO ACTION / RESTRICT); naming = auto `{T1}_{T2}` + optional `as` (vs auto-only); available in **both modes** (Simple-category DSL, like the sibling relationship commands). FK columns named `{parent_table}_{pk_column}` (disambiguates shared `id`; generalises to compound parents via ADR-0043), typed via `fk_target_type` (ADR-0011). A distinct `Command::CreateM2nRelationship` (not lowered to `CreateTable`) preserves command identity (X5) and lets the teaching echo speak in m:n terms. Cross-cutting wiring enumerated: separate `CREATE_M2N` `CommandNode` (own `help_id`/`usage_ids`), `("m","m:n")` completion composite, `HintMode`s, grammar-driven highlighting, `help`/`help create`, `parse_error_pedagogy` near-miss matrix, teaching echo. OOS: **self-referential m:n** (`from T to T`) refused outright (user-confirmed "full stop" — directional column-naming is more than this beginner convenience warrants); per-relationship action overrides; extra junction payload columns; m:n diagram echo; renaming the auto-generated relationships
-- [ADR-0046 — Schema sidebar focus/navigation mode and responsive input & hint layout (UI #20/#21/#23)](0046-sidebar-navigation-and-responsive-input-hint.md) — **Accepted + implemented 2026-06-10, phased A→B→C** (8 commits `9f5f76b`…`22bec61`; closes Gitea **#20** hint jumpiness, **#21** left-column improvements, **#23** long input — all forks user-confirmed, including the persistent show/hide toggle which is **deferred**: the Ctrl-O peek covers #21's "keystroke to show and hide"). Two decisions landed differently from the draft (recorded inline): relationship data on **`App`** not `SchemaCache` (DB2); the nav overlay clears **only the sidebar strip + a one-column gutter**, panels staying visible behind (DC2). Treats the three UI issues as one coupled decision because they share the terminal's width/height budget. **Phase A (input & hint):** the hint panel's height becomes a function of **terminal geometry, fixed between resizes** (not of hint content), eliminating the #20 jump at its source — measured catalog shows ≥ ~54-col right-column width never needs > 2 hint lines, so 3 lines is a rare narrow-terminal-only case; height buckets `H<40` compact (input 1 row + horizontal scroll / hint 2) vs `H≥40` comfortable (input 2 rows soft-wrap / hint 2), output `Min(5)` honoured first under degradation; input gains horizontal scroll (`input_scroll_offset`, single logical `String` — **not** I1 multi-line) and 2-row soft-wrap display when tall, preserving ADR-0027's 6-col indicator reserve. **Phase B (sidebar):** the 26-col Tables column is **kept but made optional and richer** (not deleted — pedagogy wins ties) — **width-derived session-only** visibility (visible iff width > 90 or a Ctrl-O peek is active — no stored field; hides at width ≤ 90 so the 90-col screencasts drop it; ADR-0015 format untouched), plus a **relationships panel** rendered narrow with endpoints broken at the arrow, ellipsized — a **separate sibling panel** that **overrides S2**'s nested-list extension model (relationships are cross-table). the full records live on a new **`App.relationships`** field (revised from the ADR's original `SchemaCache.relationship_details` at implementation — `SchemaCache` is walker-facing and needs only the names, kept in `relationships: Vec`; details are UI-only, so `App` mirrors `app.tables` and avoids ~23 fixture edits), delivered by `Database::read_all_relationships` + an `AppEvent::RelationshipsRefreshed`; the two left panels split vertically with the relationships panel floored at 5 rows ("(none)" when empty) and capped at 50 % of the column (DB4). **Phase C (navigation mode):** **`Ctrl-O`** enters a focus cycle (Input → Tables → Relationships → Input; `Esc` exits) orthogonal to the ADR-0003 input mode — **`Ctrl-B` was rejected on review as the default tmux prefix** (unreachable inside tmux); the focused panel **expands to ~40–50 cols as a `Clear` overlay** (right panels stay unchanging underneath) and scrolls via **Up/Down (line) + PageUp/PageDown (page)** (context-rebind, reusing the output-scroll viewport mechanism), with an accent focus border; all non-nav keys inert in nav mode (and nav keys inert while a modal is open). Forks all user-chosen: keep-optional-richer (vs remove/narrow); navigation-mode (vs modeless modifier scroll); `Ctrl-O` (Ctrl-B rejected = tmux prefix); overlay (vs layout re-split); inert-non-nav-keys; geometry-fixed hint height; `H<40/≥40` thresholds; session-only persistence; Up/Down line-scroll; **separate relationships panel overriding S2**; **no hint-area toggle** (S4's stale "keyboard-toggleable" claim struck — never implemented, unwanted). A pre-build `/runda` DA pass drove these corrections: caught the `Ctrl-B`/tmux collision, the `SchemaCache` retype that would have broken completion, the 2-row-input/indicator placement, the missing nav-mode key disposition + modal gate, and three unreferenced requirements (S1 evolved, S2 overridden, S4 corrected); also cross-checked open issue **#22** (overlay/annotation layer — separate ADR, adjacent). OOS: true multi-line input (I1); readline shortcuts (I1b); cross-session sidebar persistence; output as a third nav focus; relationship search/edit from the panel; hint-area toggle; #22's annotation layer. Accepted consequence: the 90-col visibility threshold makes a terminal's output *narrower* when widened across the boundary (sidebar appears)
+- [ADR-0046 — Schema sidebar focus/navigation mode and responsive input & hint layout (UI #20/#21/#23)](0046-sidebar-navigation-and-responsive-input-hint.md) — **Accepted + implemented 2026-06-10, phased A→B→C** (8 commits `9f5f76b`…`22bec61`; closes Gitea **#20** hint jumpiness, **#21** left-column improvements, **#23** long input — all forks user-confirmed, including the persistent show/hide toggle which is **deferred**: the Ctrl-O peek covers #21's "keystroke to show and hide"). Two decisions landed differently from the draft (recorded inline): relationship data on **`App`** not `SchemaCache` (DB2); the nav overlay clears **only the sidebar strip + a one-column gutter**, panels staying visible behind (DC2). Treats the three UI issues as one coupled decision because they share the terminal's width/height budget. **Phase A (input & hint):** the hint panel's height becomes a function of **terminal geometry, fixed between resizes** (not of hint content), eliminating the #20 jump at its source — measured catalog shows ≥ ~54-col right-column width never needs > 2 hint lines, so 3 lines is a rare narrow-terminal-only case; height buckets `H<40` compact (input 1 row + horizontal scroll / hint 2) vs `H≥40` comfortable (input 2 rows soft-wrap / hint 2), output `Min(5)` honoured first under degradation; input gains horizontal scroll (`input_scroll_offset`, single logical `String` — **not** I1 multi-line) and 2-row soft-wrap display when tall, preserving ADR-0027's 6-col indicator reserve. **Phase B (sidebar):** the 26-col Tables column is **kept but made optional and richer** (not deleted — pedagogy wins ties) — **width-derived session-only** visibility (visible iff width > 90 or a Ctrl-O peek is active — no stored field; hides at width ≤ 90 so the 90-col screencasts drop it; ADR-0015 format untouched), plus a **relationships panel** rendered narrow with endpoints broken at the arrow, ellipsized — a **separate sibling panel** that **overrides S2**'s nested-list extension model (relationships are cross-table). the full records live on a new **`App.relationships`** field (revised from the ADR's original `SchemaCache.relationship_details` at implementation — `SchemaCache` is walker-facing and needs only the names, kept in `relationships: Vec`; details are UI-only, so `App` mirrors `app.tables` and avoids ~23 fixture edits), delivered by `Database::read_all_relationships` + an `AppEvent::RelationshipsRefreshed`; the two left panels split vertically with the relationships panel floored at 5 rows ("(none)" when empty) and capped at 50 % of the column (DB4). **Phase C (navigation mode):** **`Ctrl-O`** enters a focus cycle (Input → Tables → Relationships → Input; `Esc` exits) orthogonal to the ADR-0003 input mode — **`Ctrl-B` was rejected on review as the default tmux prefix** (unreachable inside tmux); the focused panel **expands to ~40–50 cols as a `Clear` overlay** (right panels stay unchanging underneath) and scrolls via **Up/Down (line) + PageUp/PageDown (page)** (context-rebind, reusing the output-scroll viewport mechanism), with an accent focus border; all non-nav keys inert in nav mode (and nav keys inert while a modal is open). Forks all user-chosen: keep-optional-richer (vs remove/narrow); navigation-mode (vs modeless modifier scroll); `Ctrl-O` (Ctrl-B rejected = tmux prefix); overlay (vs layout re-split); inert-non-nav-keys; geometry-fixed hint height; `H<40/≥40` thresholds; session-only persistence; Up/Down line-scroll; **separate relationships panel overriding S2**; **no hint-area toggle** (S4's stale "keyboard-toggleable" claim struck — never implemented, unwanted). A pre-build `/runda` DA pass drove these corrections: caught the `Ctrl-B`/tmux collision, the `SchemaCache` retype that would have broken completion, the 2-row-input/indicator placement, the missing nav-mode key disposition + modal gate, and three unreferenced requirements (S1 evolved, S2 overridden, S4 corrected); also cross-checked open issue **#22** (overlay/annotation layer — separate ADR, adjacent). OOS: true multi-line input (I1); readline shortcuts (I1b); cross-session sidebar persistence; output as a third nav focus; relationship search/edit from the panel; hint-area toggle; #22's annotation layer. Accepted consequence: the 90-col visibility threshold makes a terminal's output *narrower* when widened across the boundary (sidebar appears); **Amendment 1, 2026-06-12** (issue #25): DC3's focus accent is now a **non-bold accent colour** (`theme.mode_simple`, blue) rather than bold bright-`fg` — bold box-drawing glyphs render as broken/gapped line-art in the asciinema cast player (and are fragile in some terminals), so `panel_border_style` carries no `Modifier::BOLD` on a border (bold stays fine on text spans); pure style change — the text-only Tier-2 snapshots were unaffected, the Tier-1 assertion was updated, and a render-level test now checks the focused border cells carry the accent and no bold
- [ADR-0047 — Demonstration overlay layer (keystroke badges + step captions)](0047-demonstration-overlay-layer.md) — **Accepted 2026-06-10; implemented 2026-06-11, phased A→B→C (closes Gitea #22)** (commits `f879d54`→`2d0f4b2`; no `requirements.md` item — tracked by issue + ADR per convention; all forks user-confirmed + a pre-build `/runda` pass that produced 10 tightening findings and a whole-implementation `/runda` pass that returned PASS, no blockers). An in-app **demonstration mode** (`--demo` flag / `RDBMS_PLAYGROUND_DEMO` env, **off by default, zero footprint when off**) that renders two transient overlays so `autocast` screencasts — and live teaching, and a future guided-lesson system — can show otherwise-invisible interactions. **Keystroke badges** (`[TAB]`, `[ENTER]`, `[UP]`, …): **automatic, app-detected** over a fixed set of glyph-less keys (the app already sees every key, so it re-records for free), label via a pure `demo_badge_label(&KeyEvent)`; the badge **auto-expires on a ~1.5 s timer** that extends the runtime's existing time-boxed-`recv` arm condition (`debounce.is_armed() || badge_pending`; expiry `Instant` in the runtime, `App.demo_badge` the render mirror — mirroring the `input` vs `input_indicator` split). **Step captions**: a **stealth, control-code-delimited input buffer** toggled by **`Ctrl+]`** (byte `0x1D` → arrives as `Char('5')+CONTROL`, verified against crossterm 0.29 `parse.rs:110-113`; chosen over `Ctrl+!`, which is **not a single ASCII byte so autocast cannot send it** — the same wall as arrow keys, R4) — typed characters accumulate **invisibly** (prompt untouched, no echo/history), `Backspace` edits, other keys inert, a second `Ctrl+]` **commits** to the caption box (empty commit dismisses); lives in pure-sync `App::update()`, **intercepted before the modal gate** so captions/badges work **over the load picker** (the `#24` projects cast). Both render as **floating flat black-on-yellow rectangles** (solid fill, **no border glyphs** — a one-cell text margin, deliberately unlike the app's bordered panels; user decision post-build, `2d0f4b2`) **at the output panel's inner bottom-right**, drawn **last over modals**, badge **stacked above** the caption, **no layout reflow**; caption **word-wraps to ≤ 3 lines** (3–5 rows), badge fixed 3 rows; clamp/skip guard for tiny terminals; a new **`App.last_output_area: Rect`** (set in `render_output_panel`) gives the top-level draw the anchor. Caption persists **until the next keystroke**; badge suppressed while capturing. Forks all user-chosen: `--demo` activation (vs hidden command / chord); automatic badges (vs scripted); stealth buffer (vs typed-command / preloaded-file); floating bottom-right boxes (vs HUD / banner / subtitle); `Ctrl+]` trigger; wrap-to-3-line captions; ~1.5 s badge / next-keystroke caption timing. Tested test-first across Tier 1 (label fn, capture state machine incl. over-modal + demo-off gate, nearest-deadline helper), Tier 2 (insta snapshots: badge/caption/both-stacked at 90×26 light+dark, short-terminal clamp), Tier 3 (`--demo` plumbing, badge set/suppressed, caption-without-input wiring), CLI (`--demo` parse + env fallback) — with an **honest limit** noted: the `tokio` timer wiring inside `run_loop` is exercised via the pure pieces + Tier-3 plumbing, not a standalone integration test of the timeout (same posture as the existing `IndicatorDebounce`). One intentional, user-acknowledged behaviour: `Ctrl-C` is inert while capturing (every non-`Ctrl+]` key is, by spec). Final tally **2290 passing / 0 failing / 0 skipped** (1 long-standing ignored doctest), clippy clean. OOS: scripted/manual badge push; badges for glyph keys; configurable styling/placement; the guided-lesson system itself (own ADR); cross-session/-switch persistence; localised caption content; arrow-only cast interactions (output-pane scroll); wiring the overlays into the website `casts.mjs` scripts (website-branch follow-up). Implementation phased **A** (`--demo` plumbing) → **B** (badges) → **C** (captions) + a flat-rectangle restyle
-- [ADR-0048 — `seed` fake-data generation command](0048-seed-fake-data-generation.md) — **Accepted 2026-06-11; Phase 1 + Phase 2 implemented 2026-06-11** (Phase 1 commits `202e25a`→`fbd219b`; design settled with the user across an extended fork dialogue, hardened by a pre-build `/runda` pass (six blockers folded in), a post-implementation `/runda` pass (eight gaps closed — FK/shortid determinism so **D4 holds with no exceptions**, plus six untested ADR decisions), and a Phase-2 pre-build `/runda` pass (which caught the no-date-literal-token reality → the D2 quoted-dates amendment), and a post-implementation `/runda` pass (which added a friendly error for a bounded override on a UNIQUE column — see D2); **2400 tests pass, clippy clean**). Closes `requirements.md` **SD1** and the core of **SD2**; closes the `seed` half of **A1**. **Phase 1 shipped:** whole-row `seed
[count] [--seed ]` with realistic name-aware generation (the `fake` crate + a type-gated heuristic catalogue, table-context name disambiguation, hand-rolled `product` generator, bounded dates), identifier + constraint uniqueness incl. junction distinct-combos, FK sampling from existing parent rows (empty-parent error), `IN`-CHECK derivation + complex-CHECK advisory, a required-column block guard, `--seed` reproducibility (serial/FK/shortid all deterministic), undo as one batch step, replay as a data write, a capped auto-show preview, the enum/CHECK advisory, and an O(N) single-transaction insert path. **Phase 2 shipped (2026-06-11):** the `set` override clause (D2 — fixed value / pick-list / `as ` / `between` range, **quoted** dates per the D2 amendment, type-aware, override drops the column from the advisory) and the `
.` column-fill form (D1 form 2 — an UPDATE over existing rows, refusing PK/autogen targets, empty-table no-op, FK/unique-respecting, one undo step), with the new `KNOWN_GENERATORS` vocabulary (D9), a range `Generator`, full completion/highlight (`HighlightClass::Function`)/validity (`IdentSource::Generators`)/help/pedagogy wiring, and the D13 advisory's Phase-2/3 wording. Further SD2 increments (custom generators, NULL injection, multi-locale, recursive auto-seed) out of scope. Closes `requirements.md` **SD1** and the core of **SD2**; closes the `seed` half of **A1** (the other being `hint`/**H2**). A dedicated `seed` command (own AST variant + `do_seed` executor, **both modes**) generating **realistic, name-aware** fake data. Two forms: **`seed
[count]`** (new rows, default **20**, capped) and **`seed
.`** (fill a column on existing rows, an UPDATE). Generation adds the **`fake` crate** (v5, English) driven by a **type-gated, token-matched name-heuristic catalogue** (~30 patterns, documented false-positive guards), with **table-context** disambiguating the `name`/`title` family (`products.name`→product, `users.name`→person, `vendors.name`→company), a **hand-rolled `product` generator** (`fake` has no commerce module), **bounded dates** (`date`/`timestamp`/`dob`/`*_at` recognised, recent windows — never "all of history"), the **identifier family** (`id`/`code`/`ref`/`number`, non-FK/non-PK) → **unique sequential**, and **enum-ish names** (`role`/`status`/`type`/…) left generic + a **post-seed Hint advisory** pointing at `set … in (…)`. A **`set` override clause** — `= value` / `in (a,b,c)` / `as ` / `between a and b` (numeric **and** date), reusing ADR-0026 operators — answers the heuristic-miss case. **`--seed `** makes runs reproducible (and enables exact-value tests). **FK** columns sampled uniformly from existing parent rows (**empty parent → friendly error**, no recursion v1); **junction/compound-PK** tables seeded with **distinct combinations**, capped + noted (SD1). A **required-column block guard** refuses rather than NULL-violate a `NOT NULL` column it can't fill (e.g. `NOT NULL blob`). Full ambient wiring (completion incl. a new generator-name vocabulary highlighted as `tok_function`, hints, `help seed`, ADR-0042 near-miss matrix, ADR-0027 validity); **no DSL→SQL teaching echo** (seed is a utility command, not a SQL twin). Honours **X5** — `do_seed` reuses insert/update *mechanics as helpers*, not by emitting `Command::Insert`. Implementation phased: (1) core whole-row seed → (2) `set` overrides → (3) column-fill. Deferred (future SD2): recursive auto-seed, NULL injection, multi-locale, user-defined custom generators, full per-column report
+- [ADR-0048 — `seed` fake-data generation command](0048-seed-fake-data-generation.md) — **Accepted 2026-06-11; Phase 1 + Phase 2 implemented 2026-06-11** (Phase 1 commits `202e25a`→`fbd219b`; design settled with the user across an extended fork dialogue, hardened by a pre-build `/runda` pass (six blockers folded in), a post-implementation `/runda` pass (eight gaps closed — FK/shortid determinism so **D4 holds with no exceptions**, plus six untested ADR decisions), and a Phase-2 pre-build `/runda` pass (which caught the no-date-literal-token reality → the D2 quoted-dates amendment), and a post-implementation `/runda` pass (which added a friendly error for a bounded override on a UNIQUE column — see D2); **2400 tests pass, clippy clean**). Closes `requirements.md` **SD1** and the core of **SD2**; closes the `seed` half of **A1**. **Phase 1 shipped:** whole-row `seed
[count] [--seed ]` with realistic name-aware generation (the `fake` crate + a type-gated heuristic catalogue, table-context name disambiguation, hand-rolled `product` generator, bounded dates), identifier + constraint uniqueness incl. junction distinct-combos, FK sampling from existing parent rows (empty-parent error), `IN`-CHECK derivation + complex-CHECK advisory, a required-column block guard, `--seed` reproducibility (serial/FK/shortid all deterministic), undo as one batch step, replay as a data write, a capped auto-show preview, the enum/CHECK advisory, and an O(N) single-transaction insert path. **Phase 2 shipped (2026-06-11):** the `set` override clause (D2 — fixed value / pick-list / `as ` / `between` range, **quoted** dates per the D2 amendment, type-aware, override drops the column from the advisory) and the `
.` column-fill form (D1 form 2 — an UPDATE over existing rows, refusing PK/autogen targets, empty-table no-op, FK/unique-respecting, one undo step), with the new `KNOWN_GENERATORS` vocabulary (D9), a range `Generator`, full completion/highlight (`HighlightClass::Function`)/validity (`IdentSource::Generators`)/help/pedagogy wiring, and the D13 advisory's Phase-2/3 wording. Further SD2 increments (custom generators, NULL injection, multi-locale, recursive auto-seed) out of scope. Closes `requirements.md` **SD1** and the core of **SD2**; closes the `seed` half of **A1** (the other being `hint`/**H2**). A dedicated `seed` command (own AST variant + `do_seed` executor, **both modes**) generating **realistic, name-aware** fake data. Two forms: **`seed
[count]`** (new rows, default **20**, capped) and **`seed
.`** (fill a column on existing rows, an UPDATE). Generation adds the **`fake` crate** (v5, English) driven by a **type-gated, token-matched name-heuristic catalogue** (~30 patterns, documented false-positive guards), with **table-context** disambiguating the `name`/`title` family (`products.name`→product, `users.name`→person, `vendors.name`→company), a **hand-rolled `product` generator** (`fake` has no commerce module), **bounded dates** (`date`/`timestamp`/`dob`/`*_at` recognised, recent windows — never "all of history"), the **identifier family** (`id`/`code`/`ref`/`number`, non-FK/non-PK) → **unique sequential**, and **enum-ish names** (`role`/`status`/`type`/…) left generic + a **post-seed Hint advisory** pointing at `set … in (…)`. A **`set` override clause** — `= value` / `in (a,b,c)` / `as ` / `between a and b` (numeric **and** date), reusing ADR-0026 operators — answers the heuristic-miss case. **`--seed `** makes runs reproducible (and enables exact-value tests). **FK** columns sampled uniformly from existing parent rows (**empty parent → friendly error**, no recursion v1); **junction/compound-PK** tables seeded with **distinct combinations**, capped + noted (SD1). A **required-column block guard** refuses rather than NULL-violate a `NOT NULL` column it can't fill (e.g. `NOT NULL blob`). Full ambient wiring (completion incl. a new generator-name vocabulary highlighted as `tok_function`, hints, `help seed`, ADR-0042 near-miss matrix, ADR-0027 validity); **no DSL→SQL teaching echo** (seed is a utility command, not a SQL twin). Honours **X5** — `do_seed` reuses insert/update *mechanics as helpers*, not by emitting `Command::Insert`. Implementation phased: (1) core whole-row seed → (2) `set` overrides → (3) column-fill. Deferred (future SD2): recursive auto-seed, NULL injection, multi-locale, user-defined custom generators, full per-column report. **Amendment 1, 2026-06-12** (issues #33/#34): two additive D7 catalogue rules — **year-as-int** (`year`/`*_year`/`published`/`founded` → a bounded `int` year, 1950–2025, or the `dob`-style birth window 1945–2007 for `birth`/`born`/`dob`; fixes nonsense like `9419`; `int`-gated, after the quantity rule so `year_count` stays a count; two new `YearRecent`/`YearBirth` generators, *not* added to the D9 vocabulary) and **conventional choice sets** (`priority`/`prio`, `severity`, `rating`/`stars` → type-gated built-in `PickFrom` value sets reusing the existing generator; `priority` leaves `ENUM_TOKENS`). `status` is **deliberately excluded** (user-confirmed — values too domain-specific; keeps the D12 "don't guess" + advisory); a user `IN`-CHECK still wins. Website `seed` cast re-record tracked on the `website` branch
+- [ADR-0049 — Input-field readline keymap: Esc-clear + Ctrl-A/E/W/K/U (I1b)](0049-input-field-readline-keymap.md) — **Accepted + implemented 2026-06-12 (issue #29)**, closes Gitea **#29** and the deferred **I1b** readline requirement. **Amends ADR-0046**, which listed "readline shortcuts (I1b)" as out-of-scope — that item is now in scope and decided here; orthogonal to ADR-0003's input-*mode* model and extends the I1a single-line cursor editing already shipped. Binds, in the input field (non-modal, non-nav, both modes): **`Esc`** clears a partly-typed command (empty buffer, cursor→0, scroll→0); **`Ctrl-A`/`Ctrl-E`** alias Home/End (line start/end); **`Ctrl-W`** deletes the previous word (readline-style — eats trailing whitespace then the preceding non-whitespace run, UTF-8-safe on char boundaries, only back to the cursor); **`Ctrl-K`** kills to end of line; **`Ctrl-U`** kills to start. **Esc precedence:** a live Tab-completion memo still wins (Esc undoes the completion first, ADR-0022; Esc clears only when no memo) — Esc-once backs out the completion, Esc-again clears. Forks all user-chosen: **single-Esc-clears** (not double-Esc — discoverable over accident-proof; an unsubmitted draft can be lost, a submitted line is always in history); the **full I1b set** (not just the issue's literal Ctrl-A/E + Esc); a **new ADR** (not an ADR-0046 amendment / no-ADR). Cursor-only keys (Ctrl-A/E) leave history navigation intact like Home/End; buffer-mutating keys (Esc-clear, Ctrl-W/K/U) end it like Backspace. Helpers `clear_input`/`delete_prev_word`/`kill_to_end`/`kill_to_start` in `src/app.rs`; **22 new Tier-1 tests, 2458 pass / 0 fail / 0 skip (1 ignored), clippy clean**. OOS: on-screen keybinding hints (issue #27 owns surfacing per-focus keybindings in the bottom status line — this ADR makes the keys *work*, #27 makes them *discoverable*); demo-mode badges for the new chords (ADR-0047 follow-up — Esc already badges `[ESC]`, the glyph-less Ctrl-chords are flagged but not added); multi-line input (I1); word-wise cursor motion (Alt-B/F) / transpose / yank
+- [ADR-0050 — Incidental-DDL confirmations omit relationship info (structure-only)](0050-incidental-ddl-confirmations-omit-relationships.md) — **Accepted + implemented 2026-06-12 (issue #28)**, closes Gitea **#28**. **Supersedes** the incidental-DDL clause of **ADR-0044 §1** and the relationship-block half of **ADR-0016 §5**. Incidental-DDL confirmation echoes (`create table`, `add`/`drop`/`rename`/`change column`, `add`/`drop index`) now render **structure only** — header + column box + `Indexes:` + constraints — with **no `References:` / `Referenced by:` block** (neither prose nor diagram), even when the table carries relationships the user did not touch. Rationale (owner): a confirmation echo reports the change just made, not untouched relationships; ADR-0044's terse prose was the lesser of "prose vs diagram", but the right answer for these surfaces is **neither**. **Relationship-subject surfaces are unchanged** — `show table`, `add`/`drop relationship`, `show relationship` still render ADR-0044 diagrams; relationships appear only when the user asks for (`show table`) or acts on (`add`/`drop relationship`) one, and are one `show table ` away — **no information lost**. Forks both user-chosen: **scope = all incidental DDL** (not just `add column` — the rationale is uniform, the mental model clean, and it's the simpler edit) and **delete the prose renderer** (not retain it dormant — no dead code). **Mechanism:** the `handle_dsl_success` `matches!` routing is unchanged (relationship-subject → diagrams; else → `render_structure`); the change is one line inside `render_structure` (`output_render.rs` — drop the relationship-block call) since all its callers are incidental DDL, plus deletion of the orphaned `relationship_prose_lines` + `cols_disp` helpers. The prose format survives in ADR-0016 §5 + git history for a future OOS-7 always-prose setting. **Tests:** the prose-presence unit test + its snapshot removed; a new unit test asserts `render_structure` on a description carrying **both** inbound and outbound relationships emits the box but no prose; the misnamed `add_relationship_flow_shows_inbound_section_on_parent` integration test (which sent an `AddColumn`) inverted + renamed to assert the add-column echo omits the prose; the diagram tests (`show table`, `add relationship`) unaffected. **2458 pass / 0 fail / 0 skip (1 ignored), clippy clean**. `requirements.md` unaffected (ADR-tracked refinement of a decided area, like ADR-0044 itself)
+- [ADR-0051 — Bottom keybinding strip: context- and state-aware](0051-context-state-aware-keybinding-strip.md) — **Accepted + implemented 2026-06-13 (issue #27)**, closes Gitea **#27**. Repurposes the bottom status line into a **keystrokes-only, state-selected** strip (builds on ADR-0046 nav focus, ADR-0003 modes, ADR-0049 the #29 readline keys it now advertises, ADR-0022 the completion memo). A pure `status_bar_bindings(app) -> Vec<(key,label)>` chooses the strip by **priority, first match wins**: (1) **sidebar focus** → `Ctrl-O next pane · ↑↓/PgUp/PgDn scroll · Esc input`; (2) **completion memo live** (`last_completion`) → `Tab/Shift-Tab cycle · Esc cancel · Enter run`; (3) **history navigation** (new `App::is_browsing_history()` exposing the private `history_cursor`) → `↑↓ browse · Esc clear · Enter run`; (4) **editing** (input non-empty) → `Esc clear · Ctrl-A/E home/end · Ctrl-W del word · Enter run` (surfaces the #29 keys, closing ADR-0049's deferred advertisement); (5) **default** (empty) → `Ctrl-O sidebar · Tab complete · ↑ history · Enter run`. Priority is correct because Up clears the completion memo and Tab cancels history nav, so states 2/3 never co-occur, and the five are exhaustive for Input focus. **Typed-command words leave the strip** (`mode advanced`/`mode simple` switch, `:` one-shot) and **mode discovery moves to the empty-input hint** (`resolve_hint_lines`), **simple mode only**: `\`mode advanced\` for SQL` (the verb "type" omitted — the prompt implies it; advanced mode shows **no** pointer per a post-trial user decision — a switcher knows how they got there and `help` covers the way back). The one-shot's old `Backspace cancel one-shot` label is subsumed by the editing state (behaviour intact). Forks all user-chosen: **editing state shows the #29 keys** (vs unadvertised); **`Ctrl-C quit` omitted** from the strip (vs always shown); **no width-drop machinery** — the longest strip (~65 cols) fits all supported widths, so a **width-budget unit test** keeps it lean by construction instead (the user's own observation). Catalog: 12 new `shortcut.*` labels + the `panel.hint_mode_advanced` string added to `en-US.yaml`+`keys.rs` (validator-checked 1:1), 5 now-dead strip strings removed. **Modal-aware strip is OOS** (pre-existing: a modal owns the keyboard and carries its own hints; the strip under it is unchanged-in-kind, not worsened). Tests: 9 Tier-1 unit (per-state key sets — completion/history driven through real key events; width budget; mode-pointer presence/absence), 1 Tier-3 rewritten (`status_bar_is_keystroke_only_and_state_aware`), 15 full-panel snapshots re-accepted (reviewed — strip/hint only). **2467 pass / 0 fail / 0 skip (1 ignored), clippy clean.** OOS: modal-aware strip; a full-key cheatsheet overlay; Ctrl-K/U advertisement (editing strip shows the highest-value subset within the width budget)
+- [ADR-0052 — Mode-tagged history for cross-mode recall](0052-mode-tagged-history-cross-mode-recall.md) — **Accepted + implemented 2026-06-13 (issue #30)**, closes Gitea **#30** — the feature (advanced history reusable in simple mode) **and** the bug in its comment (the `:` one-shot prefix lost across sessions). **Amends ADR-0034** (status field gains a `:adv` tag; **journaling moves from the worker to the dispatch layer**), **ADR-0015 §5/§6** (history.log leaves the worker transaction — `commit-db-last` now scopes yaml/csv/db only), and **ADR-0040** (a success-path journal-write failure is best-effort, not fatal); references ADR-0003. **Root cause:** history carried no mode, and the in-memory ring stored the raw `:select 1` while the worker journalled the *stripped* `select 1`, so the `:` was lost on disk. **Fix:** record the submission mode per entry as a **`:adv` suffix on the status token** (`ok`/`ok:adv`/`err`/`err:adv`) — `source` stays last + canonical so replay is unaffected; the in-memory ring (still `Vec`) stores advanced entries in their `: `-prefixed simple-mode runnable form (a leading `:` unambiguously marks advanced since simple DSL never starts with `:`); recall **strips the `:` in advanced mode** (runs as bare SQL) and keeps it in simple mode (runs via the one-shot escape); hydration reconstructs the `: `-prefix from the tag, so cross-session = in-session. **The architectural turn (user's call):** the first draft kept journaling in the worker + threaded the mode down (~30-site plumbing); on review the user asked why the journal is written deep in the worker when the *failure* path already journals at the top of the chain — it shouldn't (history.log is a journal, not state). So **success journaling moved up** to `spawn_dsl_dispatch` / `run_replay` / the app-command sites (next to the failure path), the worker's `finalize_persistence` now writes only yaml/csv, and the journal write became **best-effort** (the command is already committed — consistent with the failure path; a rare disk-full leaves a committed command unjournalled, state intact). **App commands** journal simple (dispatched outside the spawn) and `submit` excludes them from the ring's advanced flag, so `undo`/`mode advanced` recall bare. Forks user-chosen: status-tag format (vs 4th field / `:`-in-source); unified scope; **dispatch-layer best-effort journaling** (vs worker-coupled-fatal). Two `/runda` passes (the second drove the relocation + app-command exclusion). Tests: the 15 worker-level journaling tests retired (worker no longer journals — yaml/csv/operation checks kept), re-covered at the new layer (history.rs status-tag + `:`-reconstruct; app.rs recall matrix; the #30 cross-session regression in `iteration6`; replay tests cover `run_replay` journaling). **2471 pass / 0 fail / 0 skip (1 ignored), clippy clean.** replay re-journaling mode-fidelity (a replayed advanced line re-journals simple — not a regression). **Follow-up done 2026-06-14:** the vestigial worker `source` plumbing was fully unwound (compiler-guided, no behaviour change) — `_source` removed from `finalize_persistence`/`do_rebuild_from_text`, the three `*_request` wrappers inlined+deleted, the dead `source` param dropped from the ~30 forwarding worker handlers, and the `source` field removed from the `DescribeTable`/`QueryData`/`RunSelect` requests + their `DatabaseHandle` methods (~164 mostly-test call sites); the only worker `source` left is the snapshot/undo label (see ADR-0052 *Consequences*)
+- [ADR-0053 — Contextual `hint` command and keybinding](0053-contextual-hint-command-and-keybinding.md) — **Accepted, implementation in progress (2026-06-14; Phase A done, Phase B underway)**. Settles the `hint` slot ADR-0003 left "ADR pending"; closes the last open piece of **A1** and tracks requirements **H2**. **Two surfaces:** an **F1 keybinding** that renders a deep hint for the *live* partial input without submitting (the primary path — a submitted `hint` command can't see the buffer it would help with, since Enter empties it), and a submitted **`hint` command** that expands on the *most recent error*. **No topic argument** (contextual only — `help ` already owns explicit reference). Introduces a **tier-3 teaching layer**, deeper than the existing tier-1 (colour / error headline) and tier-2 (ambient one-liner; and the error `hint:`, which is shown **by default** since `Verbosity::Verbose` is the default — `messages short` is the opt-*out*); without it `hint` would just duplicate what's already on screen. Tier-3 content lives in the catalogue under `hint.cmd.` (per command form) and `hint.err.` (per error/diagnostic class), each a structured `what`/`example`/`concept` block rendered via a new `note_hint*` family with `OutputStyleClass::Hint`. **Keyed per-form via a new `hint_ids: &[&str]` field on `CommandNode` mirroring `usage_ids`** (revised in Phase B): a per-*node* key proved too coarse — `add`/`drop`/`show`/`create` are each one node spanning many forms, and a live-input hint for `add 1:n relationship` must be specific to relationships; `hint_key_for_input_in_mode` reuses `usage_key_for_input_in_mode`'s form-word disambiguation, and covers the advanced-SQL forms whose `usage_ids` are empty. Not keyed off `help_id` (it is `None` on the advanced-SQL nodes purely to dedup the `help` list; that parallel gap is issue **#36**). **Clause-concept hints** (`on delete` actions, constraint slots, `with pk`, cardinality) are a recorded **deferred extension** (`hint.concept.`, issue **#37**) — per-form is the right tier-3 granularity, with position-awareness owned by tier-2 + the live `Next:` line. Two error routes share `hint.err.*`: pre-submit `diagnostic.*` read live from the walker (F1 path), runtime `translate_error` classes via stored `last_error_hint_key` (`hint` command / empty-F1). Adds `AppCommand::Hint`, a `HINT` grammar node + REGISTRY entry, the `hint_ids` field, and `last_error_hint_key`; F1 is a read-only overlay (buffer + completion memo untouched). **Content is the bulk of the work** (the mechanism is ~a day): **comprehensive for v1** — ~37 command forms + 9 runtime error classes + ~33 `diagnostic.*` classes ≈ 80 teaching blocks — authored **exemplars-first** (voice approved in this ADR's `/runda` review, then mass-authored in batches), enforced by a **comprehensiveness coverage test** (every node/error class has a key), with graceful fall-back to tier-2 if a key is ever missing. Forks user-chosen: two-surface model; **F1** (vs `?` / a chord); no-arg; comprehensive scope; exemplars-first. OOS: per-topic `hint ` (rejected — overlaps `help`); always-on tier-3 (rejected — keeps ambient terse); non-`en-US` locales + success-command teaching (deferred); the `help`-side advanced-SQL gap (issue #36)
diff --git a/docs/handoff/20260612-handoff-68.md b/docs/handoff/20260612-handoff-68.md
new file mode 100644
index 0000000..ec65611
--- /dev/null
+++ b/docs/handoff/20260612-handoff-68.md
@@ -0,0 +1,173 @@
+# Session handoff — 2026-06-12 (68)
+
+Sixty-eighth handover. Continues directly from handoff-67 (which
+triaged a manual-testing pass into fixes + filed issues). This was an
+**issue-burndown session**: six Gitea issues closed across five
+commits, each landed with the full phased workflow + a `/runda` +
+Devil's-Advocate pass before commit. Net: **six issues closed, five
+commits, +29 tests, zero regressions.**
+
+## §1. State at handoff
+
+**Branch:** `main`. Working tree **clean**; all work committed.
+**Five unpushed commits** (push is the user's step).
+
+**Tests: 2436 passing / 0 failing / 0 skipped / 1 ignored** (the
+long-standing `friendly` doctest). **Clippy clean** (nursery, all
+targets). Breakdown: 1730 lib + 506 integration (`it`) + 200
+typing-surface-matrix. +29 over handoff-67's 2407.
+
+**Commits since handoff-67:**
+```
+ee3ccd8 feat(hint): advertise the optional seed count in the hint panel (#26)
+deb0948 feat(seed): year-as-int + conventional choice-set heuristics (#33, #34)
+fde50ce fix(ui): mark sidebar focus with an accent colour, not bold (#25)
+3d4a0fd fix(render): trim IEEE-754 noise from displayed decimal arithmetic (#32)
+7e4bc12 fix(completion): treat a bare in-scope table alias as an alias, not an unknown column (#31)
+```
+
+## §2. Issues closed this session (all committed, all tested, all `/runda`-reviewed)
+
+Each closed on `git.lazyeval.net/oli/rdbms-playground` with a summary
+comment. The `/runda` pass earned its keep on every one — see the
+"DA caught" notes.
+
+1. **#31 (`7e4bc12`) — bare table alias treated as unknown column.**
+ A bare in-scope table alias in a SQL expression (`… GROUP BY o`,
+ `o` aliasing `FROM Orders o`) got `no such column o on table …` and
+ zero completions. Now: completion offers each FROM source's
+ *qualifier* (alias-if-present-else-table) at a bare `sql_expr_ident`
+ slot; the `matched.len()==0` arm emits a targeted
+ `alias_used_as_column` / `table_used_as_column` hint after the
+ projection-alias check. **DA caught** two real bugs pre-commit: a
+ DSL leak (the hint fired for simple-mode `expr_column` refs, which
+ have no `table.column` syntax) and wrong advice for an
+ aliased-table-by-real-name — both fixed by gating on
+ `role == "sql_expr_ident"` + matching the *effective qualifier*.
+ ADR-0032 Amendment 3.
+
+2. **#32 (`3d4a0fd`) — decimal aggregation float noise.** `decimal`
+ is exact TEXT, but SQLite has no decimal type, so arithmetic
+ coerces to IEEE-754 double; `sum(price*qty)` rendered
+ `298.59999999999997`. Now `format_real_display` (db.rs) rounds REAL
+ to 15 sig figs **for display only**, wired into `format_cell`.
+ **DA caught** a real regression: I'd also wired it into
+ `render_value`, which is a *canonical identity key* for the
+ uniqueness dry-runs (`dry_run_unique`, `check_uniqueness_collisions`)
+ — rounding there would report collisions the exact-valued engine
+ wouldn't. Reverted `render_value` to exact; locked with a
+ regression test. CSV/FK-key/EXPLAIN paths stay exact. ADR-0005
+ Amendment 1.
+
+3. **#25 (`fde50ce`) — sidebar focus accent colour, not bold.** Bold
+ box-drawing glyphs render broken in asciinema casts.
+ `panel_border_style` now uses a non-bold accent colour
+ (`theme.mode_simple`); bold stays fine on text spans. **DA caught**
+ that the issue's "Tier-2 snapshots need re-accepting" was wrong —
+ `render_to_string` is text-only, so no snapshot changed. Added a
+ render-level test that inspects the actual border *cells*.
+ User visually confirmed. ADR-0046 Amendment 1.
+
+4. **#33 + #34 (`deb0948`) — seed heuristics: year-as-int + choice
+ sets.** Two additive D7 catalogue rules. **#33:** `year`/`*_year`/
+ `published`/`founded` → bounded `int` year (1950–2025, or the
+ `dob`-style birth window 1945–2007 for `birth`/`born`/`dob`); new
+ `YearRecent`/`YearBirth` generators. Placed *after* the quantity
+ rule so `year_count` stays a count. **#34:** type-gated `PickFrom`
+ sets for `priority`/`prio`, `severity`, `rating`/`stars`; `status`
+ **deliberately excluded** (user-confirmed on the issue — values too
+ domain-specific). `priority` left `ENUM_TOKENS`. A user `IN`-CHECK
+ still wins. **DA/process caught** that I'd skipped reading the issue
+ *comments* (where the `status` decision + a website cast note lived)
+ — **lesson: always read issue comments**. Also closed a
+ pre-existing column-fill integration-test gap. ADR-0048 Amendment 1.
+
+5. **#26 (`ee3ccd8`) — optional `count` advertised in the hint panel.**
+ At `seed
▮` only `set`/`--seed` chips showed; the optional
+ row count (a bare positional number) was invisible, and the prior
+ `IntroProse` attempt was reverted because `pending_hint_mode` is
+ cleared by the trailing optionals. Now `walk_optional` stashes a
+ skipped inner's `IntroProse` key into a new
+ `WalkContext.surviving_intro_hint` (key + position) before the empty
+ match clears it; a **position guard** (`pos == cursor`) stops it
+ leaking past a later `set …` clause or once the count is given. Tab
+ still cycles the keywords. Prose mentions the count, `.column`
+ column-fill, `set`, and `--seed` (user-chosen scope). **DA caught**
+ a coverage gap (advanced-mode path untested — seed runs in both
+ modes); added the test. ADR-0022 Amendment 7.
+
+## §3. Open issues — next session's candidates
+
+Four open, all on `git.lazyeval.net/oli/rdbms-playground`. **All four
+are interaction/UX design changes that need a decision or two from the
+user up front — none is a pure mechanical fix.** Read each issue body
+**and its comments** before starting (the #33/#34 lesson).
+
+- **#28 — Reconsider relationship prose in `add column` (incidental
+ DDL) confirmations** *(enhancement)*. **Revisits a decided area** →
+ needs a **new ADR** superseding the relevant part of ADR-0016 §5 /
+ ADR-0044 §1. User preference (from the issue): do **not** show the
+ `References:` / `Referenced by:` block in the add-column
+ confirmation. Confirm scope with the user (just `add column`, or all
+ incidental DDL). The highest-ceremony of the four.
+
+- **#27 — Bottom status line: keybindings-only, context- and
+ state-aware; add `mode advanced` to empty hint** *(enhancement)*.
+ Per-nav-focus keybindings (Input vs sidebar), **including transient
+ states** (Tab-cycle, history) per user preference. May warrant a
+ small ADR. Touches `src/ui.rs` rendering + the nav-focus model
+ (ADR-0046).
+
+- **#29 — Command input keystroke support.** Esc / double-Esc to clear
+ a partly-typed command; possibly Ctrl-A/Ctrl-E (Home/End). Relates
+ to the deferred **I1b readline shortcuts** (`requirements.md`).
+ **Needs a key-set decision** from the user before coding.
+
+- **#30 — History brings back all commands in both modes.**
+ Advanced-mode history entries can't replay in simple mode; proposal:
+ if we can distinguish them, prepend `:` to reuse advanced history
+ from simple mode. Interaction design; touches the input-history +
+ mode model (ADR-0003).
+
+No strong ordering. **#28** is the only one that *must* produce an ADR.
+**#29** is closest to "small once the key-set is decided." **#27** and
+**#30** are medium UX work.
+
+## §4. Carried-over follow-up (not a `main`-branch task)
+
+- **Website `seed` cast re-record** (from #34's comment thread). The
+ `website` branch ships a `seed` cast exercising a `tickets` table
+ with `priority`; now that `priority` collapses to `low/medium/high`,
+ the cast should be re-recorded (`cd website && pnpm casts seed`,
+ needs a `../target/debug` binary) so the table tightens. The issue
+ comment notes it is **likely redundant** — casts get a full
+ re-record sweep before publication. Tracked on the `website` branch,
+ **not** here. `website/` is not in the `main` tree.
+
+## §5. Other open roadmap (unchanged from handoff-67 §5)
+
+`seed` is feature-complete (`requirements.md` SD1/SD2 `[x]`, now with
+the #33/#34 catalogue refinements noted inline). User's call:
+
+- **H2 `hint`** — the last A1 gap (its own ADR).
+- **TT5 CI** — test infra exists; no CI workflow yet (the `ci` branch
+ exists — check its state before starting).
+- **TT4 PTY (Tier-4)** — ADR-0008 specifies it; not wired.
+- Larger: **V4 journal**, **tutorial/lesson system** (each needs an ADR).
+
+## §6. How to take over
+
+1. Read handoffs 66 → 67 → 68, `CLAUDE.md`, `docs/requirements.md`.
+2. Confirm green baseline: `cargo test` (expect 2436 pass / 1 ignored)
+ + `cargo clippy --all-targets` (clean).
+3. Pick from §3 (#28/#27/#29/#30). **For each, read the issue body AND
+ its comments** before designing, and **escalate the design fork to
+ the user** before coding — all four have genuine UX decisions. #28
+ needs a new ADR.
+4. Follow the project workflow: phased (requirements → divergent →
+ eval → execute → verify), test-first (failing test before the fix),
+ `/runda` + DA pass before every commit, ADR amendment for any
+ decided-area change + the README index-upkeep rule, and confirm the
+ commit message with the user before committing.
+5. Consider a `cargo sweep` at this milestone (`target/` grows across
+ sessions; see CLAUDE.md "Build hygiene").
diff --git a/docs/handoff/20260614-handoff-69.md b/docs/handoff/20260614-handoff-69.md
new file mode 100644
index 0000000..a9b037d
--- /dev/null
+++ b/docs/handoff/20260614-handoff-69.md
@@ -0,0 +1,203 @@
+# Session handoff — 2026-06-14 (69)
+
+Sixty-ninth handover. Continues from handoff-68 (an issue-burndown that
+closed #25/#26/#31/#32/#33/#34). This session **closed the four
+remaining open issues** — #29, #28, #27, #30 — each landed with the full
+phased workflow + `/runda` + Devil's-Advocate passes before commit, and
+each producing a new ADR. Net: **four issues closed, four commits, four
+new ADRs (0049–0052), +63 tests, zero regressions, the tracker is now
+empty.**
+
+The four interlock: **#29** added the input-field readline keys, **#27**
+advertises them in a state-aware status strip, and **#30**'s history
+recall now respects modes. **#30** also turned into a real architecture
+change (journaling relocation) — read §2.4 carefully before touching that
+area.
+
+## §1. State at handoff
+
+**Branch:** `main`. Working tree **clean**; all work committed. The two
+most recent commits are local (normal working state — push is the user's
+step).
+
+**Tests: 2471 passing / 0 failing / 0 skipped / 1 ignored** (the
+long-standing `friendly` doctest). **Clippy clean** (nursery, all
+targets). Breakdown: 1771 lib + 500 integration (`it`) + 200
+typing-surface-matrix. **+35 over handoff-68's 2436** (net: #29 +22, #28
++0, #27 +9, #30 +4 — its new history.rs/app.rs/iteration6 tests minus the
+15 retired worker-journaling tests; trust the live `cargo test` count).
+
+**Commits this session:**
+```
+4aeea55 feat(history): mode-tagged history + top-of-chain journaling (#30)
+eceedc1 feat(ui): context- and state-aware bottom keybinding strip (#27)
+8ac3537 feat(render): incidental-DDL confirmations show structure only, no relationships (#28)
+66c8bda feat(input): readline keymap — Esc-clear + Ctrl-A/E/W/K/U (#29)
+```
+
+**Open Gitea issues: none.** `tea issues list --state open` is empty.
+
+## §2. Issues closed this session (all committed, tested, `/runda`-reviewed)
+
+Each closed on `git.lazyeval.net/oli/rdbms-playground` with a summary
+comment.
+
+### 2.1 — #29 (`66c8bda`) — input-field readline keymap (ADR-0049)
+
+Implements the deferred **I1b** readline shortcuts: `Esc` clears a
+partly-typed command (only when no completion memo is alive — the memo
+wins first, ADR-0022); `Ctrl-A`/`Ctrl-E` = Home/End; `Ctrl-W` deletes
+the previous word (readline-style, UTF-8 safe); `Ctrl-K`/`Ctrl-U` kill to
+end/start. Cursor-only keys leave history nav intact; buffer-mutating
+keys end it. **DA caught** the need for the `Ctrl-O`+`Esc` (sidebar
+nav-exit) interaction not to clear the draft — locked with a regression
+test. `requirements.md` I1b → `[x]`.
+
+### 2.2 — #28 (`8ac3537`) — incidental-DDL confirmations: structure-only (ADR-0050)
+
+Incidental-DDL confirmation echoes (`create table`, `add`/`drop`/
+`rename`/`change column`, `add`/`drop index`) now render **structure
+only** — no `References:` / `Referenced by:` block. Relationship-subject
+surfaces (`show table`, `add`/`drop relationship`) keep their ADR-0044
+diagrams. The prose renderer (`relationship_prose_lines` + `cols_disp`)
+was deleted. **Supersedes** ADR-0044 §1's incidental-DDL prose clause and
+the relationship-block half of ADR-0016 §5 (both annotated).
+
+### 2.3 — #27 (`eceedc1`) — context- and state-aware keybinding strip (ADR-0051)
+
+The bottom status line is now keystrokes-only and **state-selected** by
+priority (sidebar focus / completion-memo / history-nav / editing /
+default). The editing state surfaces the #29 keys (closing ADR-0049's
+deferred advertisement). Mode-switch advertisements left the strip; the
+empty-input hint gained a simple-mode `` `mode advanced` for SQL `` pointer
+(advanced mode shows none — user decision). New `App::is_browsing_history()`
+exposes the private `history_cursor`. 15 full-panel snapshots re-accepted.
+
+### 2.4 — #30 (`4aeea55`) — mode-tagged history + top-of-chain journaling (ADR-0052) **← read before touching journaling**
+
+Closed both the feature (advanced history reusable in simple mode) and
+the bug (the `:` one-shot prefix lost across sessions). Two halves:
+
+1. **Mode-tagged history.** The `history.log` status token gains an
+ optional `:adv` suffix (`ok` / `ok:adv` / `err` / `err:adv`); `source`
+ stays last + canonical so replay is unaffected. The in-memory ring
+ (still `Vec`) stores advanced entries in their `: `-prefixed
+ simple-mode runnable form; recall **strips the `:` in advanced mode**
+ and keeps it in simple; hydration reconstructs the prefix from the tag.
+ App commands journal simple and are excluded from the ring's advanced
+ flag, so they recall bare.
+
+2. **Journaling relocation (the architecture change).** Success
+ journaling **moved out of the worker** to the dispatch layer
+ (`spawn_dsl_dispatch` / `run_replay` / the app-command sites), next to
+ the already-top-level failure journaling — so the submission mode is in
+ scope with no worker plumbing. `finalize_persistence` now writes only
+ the **state** sources (yaml/csv); the journal write is **best-effort**
+ (the command is already committed — consistent with the failure path).
+ **Amends ADR-0015 §6** (history.log out of the worker tx; commit-db-last
+ scopes yaml/csv/db only), **ADR-0034** (status tag + journaling
+ location), **ADR-0040** (journal-write best-effort, not fatal).
+
+ **Two DA findings, both resolved:** (a) the app-command `advanced` flag
+ must exclude app commands (else `: save as` diverges); (b) the spawn
+ journals on `outcome.is_ok()`, so journaling is now **uniform** — read
+ commands that didn't journal before (`show tables`/`show relationships`/
+ `show indexes`, `show relationship `, `explain`) now do, matching
+ ADR-0034 §1. **User-confirmed** as the more-correct behaviour (harmless
+ on replay — reads/`explain` don't mutate).
+
+ **Test migration:** 15 worker-level journaling tests were retired (the
+ worker no longer journals — their yaml/csv/operation assertions were
+ kept) and re-covered at the new layer: `history.rs` status-tag +
+ `:`-reconstruct; `app.rs` recall matrix; the cross-session regression
+ `advanced_command_journalled_then_hydrated_recalls_with_colon_in_simple`
+ in `iteration6_resume_history`; the replay tests cover `run_replay`
+ journaling.
+
+ Plan: `docs/plans/20260613-issue-30-top-of-chain-journaling.md`.
+
+## §3. Next session — start here
+
+The user's stated plan for the next session, in order:
+
+1. **Pick up the ADR-0052 follow-up** (below).
+2. **Check for any newly-filed open issues** (`tea issues list --state
+ open`) — none at handoff, but check fresh.
+3. **Then** take on remaining open tasks from the general requirements
+ (`docs/requirements.md`) — see §5.
+
+### The ADR-0052 follow-up — unwind the vestigial worker `source` plumbing
+
+When journaling moved out of the worker, the `source` that the worker
+threaded purely for journaling became dead. To avoid orphaning the param
+across ~28 handlers, the refactor **left it in place** as vestigial:
+
+- `finalize_persistence(conn, persistence, _source, changes)` — the
+ `_source` param is now unused (kept so its ~28 callers still pass
+ `source`, which they otherwise also use for `snapshot_then`).
+- `do_rebuild_from_text(conn, _persistence, _source, project_path)` —
+ both `_persistence` and `_source` vestigial.
+- Three thin read-only wrappers in `db.rs` —
+ `do_describe_table_request`, `do_query_data_request`,
+ `do_run_select_request` — now just delegate to their non-`_request`
+ twin (`do_describe_table` / `do_query_data` / `do_run_select`) with
+ vestigial `_persistence` / `_source` params and one caller each
+ (`db.rs` Request arms ~2409 / ~2749 / ~2759).
+
+**The cleanup:** remove `_source` from `finalize_persistence` + drop the
+arg at its ~28 callers (the callers keep `source` for `snapshot_then`, so
+only the `finalize_persistence(...)` call loses the arg); remove the
+`_persistence`/`_source` params from `do_rebuild_from_text`; and inline
+the three `*_request` wrappers at their single call sites (replace
+`do_describe_table_request(conn, persistence, source, name)` with
+`do_describe_table(conn, &name)`, etc.), deleting the wrappers. Purely
+mechanical, compiler-guided, no behaviour change. Establish the green
+baseline first (`cargo test`), then verify nothing moved.
+
+## §4. Carried-over follow-up (website branch, not `main`)
+
+- **Website `seed` cast re-record** (from #34, handoff-68 §4) — still
+ tracked on the `website` branch, not here. Likely redundant (full
+ re-record sweep before publication).
+
+## §5. Remaining roadmap — `docs/requirements.md` (next session's §3-step 3)
+
+With the issue tracker empty, the next work comes from the document-based
+requirements. Open / partial items worth weighing (the user picks):
+
+- **H2 `hint`** — the last A1 gap (contextual help for the current
+ command); its own ADR. (`requirements.md` H2.)
+- **TT5 CI** — runs all tiers on Linux/macOS/Windows; no CI workflow yet
+ (a `ci` branch reportedly exists — check its state first). Couples with
+ **D1–D3** (cross-platform prebuilt binaries + Homebrew/Scoop).
+- **TT4 PTY (Tier-4)** — ADR-0008 specifies the PTY harness + four
+ critical flows; still not wired (no PTY deps/tests).
+- **I1 multi-line input** (Ctrl-Enter submits, Enter inserts newline) and
+ **I5 / B3 in-flight cancellation** (Ctrl-C cancels a running command).
+- **V4 session journal** — scrollable per-session log + Markdown export
+ (the bigger UX project; own ADR).
+- **TU1 tutorial / lesson system** — design + ADR pending (acknowledged
+ in scope).
+- Smaller partials: **C3a** modify relationship (drop+add covers it
+ today), **C4** m:n convenience, **V3** ER-diagram export, the **NFR-***
+ performance/visual targets (mostly unmeasured), **N4** global rolling
+ history (OOS for v1).
+
+No strong ordering — these are the user's call. Several need a new ADR
+(H2, V4, TU1); CI/release (TT5/D1–D3) is the most "shippable-product"
+track if that's the priority.
+
+## §6. How to take over
+
+1. Read handoffs 67 → 68 → 69, `CLAUDE.md`, `docs/requirements.md`.
+2. Confirm green baseline: `cargo test` (expect **2471 pass / 1 ignored**)
+ + `cargo clippy --all-targets` (clean).
+3. `tea issues list --state open` — pick up anything new first.
+4. Then the ADR-0052 follow-up (§3), then requirements (§5).
+5. Follow the project workflow: phased (requirements → divergent → eval →
+ execute → verify), test-first, `/runda` + DA pass before every commit,
+ ADR amendment for any decided-area change + the README index-upkeep
+ rule, and confirm the commit message with the user before committing.
+6. Consider a `cargo sweep` at this milestone (`target/` grows across
+ sessions; see CLAUDE.md "Build hygiene"). (`sweep.timestamp` was
+ removed this session.)
diff --git a/docs/plans/20260613-issue-30-top-of-chain-journaling.md b/docs/plans/20260613-issue-30-top-of-chain-journaling.md
new file mode 100644
index 0000000..54eeeea
--- /dev/null
+++ b/docs/plans/20260613-issue-30-top-of-chain-journaling.md
@@ -0,0 +1,247 @@
+# Plan — issue #30: mode-tagged history + top-of-chain journaling
+
+**Status:** draft for `/runda` review (2026-06-13).
+**Issue:** #30 — advanced history reusable in simple mode (prepend `:`),
+and the bug: the `:` one-shot prefix is lost across sessions.
+**ADR:** ADR-0052 (new); amends ADR-0015 §6, ADR-0034, ADR-0040;
+references ADR-0003.
+
+## 1. Goal & root cause
+
+Two coupled needs, one root cause — **history entries carry no mode**:
+- **Bug:** the in-memory ring stores the raw `:select 1`, but the worker
+ journals the *stripped* `select 1`, so cross-session the `:` is lost
+ and the command recalls bare (unusable in simple mode).
+- **Feature:** persistent-advanced commands (`select 1` typed in advanced
+ mode) can't be told apart from simple DSL, so they can't be offered
+ back with a `:` in simple mode.
+
+Fix: **record the submission mode per entry** (status tag `:adv`), keep
+the on-disk `source` canonical, and have **recall prepend/strip `:`** for
+the current mode.
+
+## 2. The architecture insight (why this plan is shaped this way)
+
+Journaling **success** lives deep in the worker: `finalize_persistence`
+(db.rs:3096-3099) writes `history.log` *inside the db transaction, before
+`tx.commit()`*, alongside yaml/csv — plus four no-op-skip sites and three
+read-only helpers. **Failure** journaling already lives at the top
+(runtime.rs:484-495, best-effort). Threading the mode *down* to the
+worker would mean ~30 `Request` variants + `Database` methods +
+`execute_command_typed` arms — because the journal write is far from
+where the mode is known.
+
+So instead: **move success journaling up to the dispatch layer**, next to
+where failure journaling already is and where mode + outcome + source are
+all in scope. The mode then needs no plumbing. This is the correct
+separation anyway — `history.log` is an append-only *journal of what was
+typed*, not *state*; the state sources (yaml/csv/db) stay atomic in the
+worker.
+
+### Semantic changes this entails (must be vetted)
+
+1. **history.log leaves the worker transaction** (amends ADR-0015 §6).
+ `commit-db-last` still governs yaml/csv/db (the state); the journal is
+ written *after* the worker replies (i.e. after `tx.commit`), at the
+ dispatch layer.
+2. **Success-journal write failure: fatal → best-effort** (amends
+ ADR-0040). Today a failed `history.log` write on a *successful*
+ command rolls the command back and shows a fatal banner. After: the
+ command stays committed; the journal write is best-effort (logged +
+ ignored), exactly like the failure path already is. The two journal
+ paths become *consistent*.
+3. **Consequence:** on a rare journal-write failure (disk full /
+ permissions) a successful command is applied but may be missing from
+ `history.log` — not recallable next session, not replayable. The state
+ (yaml/csv/db) is unaffected and consistent. This is a graceful
+ degradation, not corruption, and is logged. (Today the same disk-full
+ instead kills the app mid-command.)
+
+**Open question for review/user:** is trading "fatal on journal-write
+failure" for "best-effort, command still succeeds" acceptable? The plan
+assumes **yes** (a journal is auxiliary; killing the app over it is worse
+UX). If not, journaling must stay coupled in the worker and we pay the
+~30-site mode plumbing instead.
+
+## 3. On-disk format (mode tag in status — already chosen + partly built)
+
+Record stays `||`; the **status token** gains an
+optional `:adv` suffix (ADR-0052). `source` stays canonical so replay is
+unaffected.
+
+| Submission | Success | Failure |
+|---|---|---|
+| Simple / app command | `ok` | `err` |
+| Advanced (SQL, persistent or one-shot) | `ok:adv` | `err:adv` |
+
+**Done already** (history.rs / mod.rs):
+- `status_token(base, advanced)`, `parse_status(status) -> (is_ok, advanced)`.
+- `parse_record_source` reconstructs `": {cmd}"` for `:adv` records.
+- `parse_journal_record.status_is_ok` via `parse_status` (so `ok:adv` replays).
+- `append_history(text, advanced)`, `append_history_failure(text, advanced)`.
+
+Back-compat: old `ok`/`err` logs → simple; nothing migrates.
+
+## 4. In-memory ring & recall (app.rs) — the #30 behaviour
+
+The ring stays `Vec`. An **advanced** entry is stored in its
+`: `-prefixed simple-mode runnable form (matching the existing in-session
+one-shot ring); a **simple** entry bare. A leading `:` unambiguously
+marks advanced (simple DSL can never start with `:`).
+
+- **`submit`** (app.rs:1704): compute `effective_input` + `submission_mode`,
+ parse once for the app-command check (already done at 1751), then build
+ the ring line. The **`advanced` flag excludes app commands** —
+ `advanced = submission_mode.is_advanced() && !is_app_command` — because
+ app commands (`undo`, `mode …`, `save as`, …) run in *any* mode and must
+ **not** get a `:` on recall. Ring line: `": " + effective_input` if
+ `advanced`, else `effective_input`; `push_history(&ring_line)`. (Today it
+ pushes the raw `trimmed` *before* stripping; the reorder also drops a
+ bare `:`, which executed nothing, and is what lets the app-command check
+ precede the push.) `ExecuteDsl.source` stays the **canonical**
+ `effective_input`.
+ - *Why the app-command exclusion matters (DA finding):* without it,
+ `: save as foo` (an app command via the one-shot) would store `: save
+ as foo` in the ring but journal `save as foo` (app commands journal
+ simple at their own sites, §5) — the very in-session-vs-cross-session
+ divergence #30 is fixing, re-introduced for app commands. Excluding
+ them keeps ring and disk agreeing (both bare).
+- **`history_back` / `history_forward`**: after cloning the stored entry
+ into `self.input`, strip a leading `:` **iff `self.mode == Advanced`**
+ (so an advanced entry runs as bare SQL in advanced mode, and as `: …`
+ one-shot in simple mode). A small helper `recall_display(stored)`.
+- `seed_history` / `ProjectSwitched` payload: **unchanged** (`Vec`);
+ hydration already returns the `: `-prefixed form (§3).
+
+Recall matrix:
+| entry \ current mode | Simple | Advanced |
+|---|---|---|
+| advanced (`: select 1`) | `: select 1` (one-shot) | `select 1` (SQL) |
+| simple (`create …`) | `create …` | `create …` |
+
+## 5. Move success journaling worker → dispatch layer
+
+**Remove** (worker stops journaling success):
+- `finalize_persistence` history write (db.rs:3096-3099). Keep yaml/csv.
+ The now-unused `source` param: remove it + drop the arg at its ~30
+ callers (mechanical, compiler-guided). (Handlers keep their own
+ `source` for `snapshot_then`.)
+- The 4 no-op-skip `append_history` (db.rs:2267, 2311, 2524, 2560) — these
+ outcomes (`SchemaSkipped` etc.) are `Ok` at the dispatch layer, so the
+ new top-level journal covers them.
+- The 3 read-only helper `append_history` (db.rs:8372 show table, 9996
+ show data, 10014 select) — `Ok(Query)`/`Ok(ShowList)` at the top.
+
+**Add** (dispatch-layer journaling, all best-effort + logged):
+- **`spawn_dsl_dispatch`** (runtime.rs ~1433): pass `project_path` in;
+ after `execute_command_typed`, `if outcome.is_ok() {
+ Persistence::new(path).append_history(&source_for_journal,
+ submission_mode.is_advanced()) }`. (Failures stay in the existing path,
+ §6 — no double-journal, since Ok and Err are exclusive.)
+- **`run_replay`** (runtime.rs ~2540): after each line's
+ `execute_command_typed`, `if outcome.is_ok() { append_history(
+ &command_text, false) }` — replay is mode-agnostic, journalled
+ **simple**. (Preserves ADR-0034 §3 "replayed sub-commands land in
+ history"; a replayed advanced command re-journals without `:adv` — a
+ documented OOS, not a regression: today it re-journals as plain `ok`.)
+- **`spawn_rebuild`** (runtime.rs ~503): after a successful rebuild,
+ `append_history("rebuild"/source, false)`. (Rebuild journalled via
+ `finalize_persistence` today; that write is gone, so add it here.)
+
+**Unchanged** (already at the dispatch layer, app commands):
+- `perform_switch` (974: save-as/load/new) and `spawn_export` (1043) —
+ already best-effort `append_history(&source)`; add the new `advanced`
+ arg as `false` (app commands run in any mode → no `:` needed on recall;
+ this also fixes the would-be "redundant `: undo`" — app commands
+ journal **simple** because they're dispatched here, never via
+ `ExecuteDsl`/the spawn).
+- `undo`/`redo`/`copy`/`help`/`quit`: not journalled today; unchanged.
+- The **`replay` command itself**: dispatched as `Action::Replay`, never
+ reaches the spawn → not journalled (preserves the ADR-0034 §3 exclusion
+ without extra work); nested `replay` skip in `run_replay` unchanged.
+
+### DA-confirmed design choice: split, don't unify
+
+Success journals in the spawn (`Ok` arm); **all** failures stay in the
+existing App→`JournalFailure`→runtime path (just gaining the mode).
+Considered and rejected: moving worker-rejection failures into the spawn
+too (to "unify"). It doesn't actually unify — parse failures never reach
+the spawn, so they'd stay in the App path regardless — and it adds a
+double-journal hazard (must also strip the App's `DslFailed`→
+`JournalFailure` emission). The split keeps the failure path **untouched
+in structure** (lowest risk); `Ok`/`Err` are exclusive so there is no
+double-journal. **Verified safe:** undo/redo never touches `history.log`
+(the snapshot copies db+yaml+csv only, undo.rs:15-16), and `snapshot_then`'s
+redo-clear keys on `source.is_some()`, independent of journaling — so
+removing the worker journal write does not perturb undo/snapshot at all.
+
+## 6. Failure journaling — add the mode (location unchanged)
+
+Keep both failure origins where they are (best-effort, dispatch/App
+layer); thread the mode so they tag `err:adv`:
+- **`Action::JournalFailure`** (action.rs:42): add `advanced: bool` (or
+ `submission_mode`).
+- **`AppEvent::DslFailed`** (event.rs): add `submission_mode` (the
+ worker-rejection path — the App can't recover the mode from an async
+ reply otherwise).
+- **App**: the parse-failure path (`dispatch_dsl` Err arm) has
+ `submission_mode` directly; the `DslFailed` handler reads it off the
+ event. Both emit `JournalFailure { source, advanced }`.
+- **runtime.rs:492**: `append_history_failure(&source, advanced)`.
+
+## 7. Tests
+
+- **history.rs (Tier-1):** `status_token`/`parse_status` round-trip;
+ `read_recent_sources` reconstructs `": …"` for `:adv` and leaves
+ `ok`/`err` bare; `status_is_ok` true for `ok` & `ok:adv`; old-log
+ back-compat.
+- **app.rs (Tier-1):** advanced submission stored `: `-prefixed; recall
+ prepends in simple / strips in advanced; simple bare in both; bare `:`
+ not stored; a parse-failure is still recallable; dedup/cap hold.
+- **iteration6_resume_history (Tier-3) — headline regression:** journal
+ an advanced command (`append_history(text, true)`), hydrate, recall in
+ simple → `: …`; and the full bug repro through `submit` + journal +
+ hydrate if feasible.
+- **replay_command (Tier-3):** replayed commands still land in
+ history.log (now via `run_replay`'s call); the `replay`-self-exclusion
+ + nested-skip still hold; advanced lines replay (status `ok:adv`
+ treated as ok).
+- **Journaling relocation:** a success no longer fatals on a journal
+ write failure (best-effort) — if cheaply testable; at minimum a worker
+ test that previously asserted worker-side journaling is updated/removed.
+- **Update mechanical call sites:** `append_history(_, advanced)` /
+ `append_history_failure(_, advanced)` at the db.rs inline tests
+ (8372/9996/10014/11324 — likely now removed with the production sites),
+ iteration6 (144-170), mod.rs (600).
+
+## 8. ADR work
+
+- **ADR-0052 (new):** the #30 feature + bug, the status-tag format, the
+ `: `-prefixed ring + recall, AND the journaling relocation (it's the
+ enabling refactor). Forks: status-tag format; unified scope;
+ dispatch-layer journaling (best-effort).
+- **ADR-0015 §6 amendment:** history.log out of the worker transaction;
+ commit-db-last now scopes yaml/csv/db; journal is a dispatch-layer
+ best-effort side-record.
+- **ADR-0034 amendment:** journaling location (dispatch layer);
+ status-field `:adv` extension (it already reserved the field).
+- **ADR-0040 amendment:** a success-path journal-write failure is no
+ longer fatal — best-effort, consistent with the failure path.
+- README index upkeep for every ADR touched.
+
+## 9. Risks / watch-list
+
+- **Double-journaling**: ensure Ok→spawn and Err→App-path stay exclusive;
+ do NOT also leave a worker journal.
+- **Under/over-journaling vs today**: top-level "journal on every Ok"
+ must match today's "journal every command with a source" — verified:
+ reads + skips are Ok outcomes, internal ops never reach the spawn.
+- **finalize_persistence source-param removal**: 30 mechanical call-site
+ edits; compiler-guided.
+- **Replay re-journal mode fidelity**: replayed advanced commands
+ re-journal as simple (OOS, not a regression).
+- **best-effort journal**: rare write-failure leaves a command unjournaled
+ (logged). User decision (§2 open question).
+- **app-command mode**: journalled simple by construction (dispatched
+ outside the spawn) — this is correct (they run in any mode), and
+ resolves the earlier "redundant `: undo`" worry.
diff --git a/docs/plans/20260614-adr-0053-contextual-hint-H2.md b/docs/plans/20260614-adr-0053-contextual-hint-H2.md
new file mode 100644
index 0000000..00391ae
--- /dev/null
+++ b/docs/plans/20260614-adr-0053-contextual-hint-H2.md
@@ -0,0 +1,243 @@
+# Plan — ADR-0053: contextual `hint` command + F1 keybinding (H2)
+
+Implements ADR-0053. Closes the last open piece of **A1** (the canonical
+app-command set) and requirements **H2**. No Gitea issue — this is
+requirements-driven work; any genuine "later" item found en route gets
+its own issue (cf. #36, already filed for the parallel `help`-side gap).
+
+## 1. Goal
+
+Give learners on-demand, **teaching-grade** contextual help — a *third*
+tier beneath the existing terse always-on text (tier 1) and the
+short contextual lines that are already shown (tier 2: the live ambient
+prose, and the error `hint:` which is on by default since
+`Verbosity::Verbose` is the default). Two surfaces:
+
+- **F1** (read-only overlay) → a tier-3 block for the **live partial
+ input**, or — on empty input — for the **most recent runtime error**.
+- **`hint`** (submitted app command) → the tier-3 block for the **most
+ recent runtime error** (the buffer is empty post-submit, so it can only
+ act on recent context).
+
+The mechanism is small; the **content corpus is the feature** (~80
+blocks, comprehensive for v1, authored exemplars-first per ADR-0053 D7).
+
+## 2. The shape of the work (why this order)
+
+The mechanism and the content are separable, and the mechanism should
+land first with **graceful tier-2 fallback** so every surface works
+before any tier-3 text exists. That lets us:
+
+- build + test the trigger matrix / routing / `:`-strip / read-only-
+ overlay behaviour against a skeleton (TDD), then
+- pour in content in reviewable batches without re-touching the wiring,
+- and turn on the **comprehensiveness coverage test** only once the
+ corpus is complete (it is red until then — by design).
+
+Build order: **Phase A** (mechanism skeleton, falls back to tier-2) →
+**Phase B** (catalogue structure + the three approved exemplars) →
+**Phase C** (comprehensive content, batched) → **Phase D** (polish:
+strip advertisement, snapshots, full green).
+
+## 3. Grammar: the `hint_ids` field + the `HINT` node
+
+### 3a. New `CommandNode.hint_ids` (per-form — revised in Phase B)
+- Add `pub hint_ids: &'static [&'static str]` to `CommandNode`
+ (`src/dsl/grammar/mod.rs:512`, beside `help_id` / `usage_ids`),
+ **mirroring `usage_ids`** — *not* a per-node `Option<&str>`. The Phase-B
+ exemplar (`add 1:n relationship`) showed per-*node* keying is too coarse:
+ `add`/`drop`/`show`/`create` are each one node spanning many forms, and
+ a live-input hint must be specific to the typed form. Compiler forces
+ every node literal (~37, across `grammar/app.rs`, `data.rs`, `ddl.rs`) to
+ set it — Phase A/B leave most `&[]` (tier-2 fallback); Phase C fills them.
+ **Multi-form nodes list ALL their form keys** (e.g. `add` →
+ `["add_column", "add_relationship", "add_index", "add_constraint"]`) so
+ the form-word disambiguation resolves correctly and unauthored forms fall
+ back at render rather than mis-resolving to a sibling.
+- **Lookup:** `hint_key_for_input_in_mode(source, mode)` returns the single
+ typed form's hint stem, reusing `pick_form_key` (factored out of
+ `usage_key_for_input_in_mode` — shared digit/`m:n`/suffix disambiguation).
+- **Why a new field, not `help_id`** (ADR-0053 D3): `help_id` is `None` on
+ the 7 advanced-SQL forms purely to dedup the `help` *list*; those forms
+ have distinct SQL syntax and need their own block. `hint_ids` is per
+ form. (The parallel `help`-side gap is issue #36; clause-concept hints
+ are deferred — issue #37.)
+
+### 3b. `AppCommand::Hint` + the `HINT` node
+- `AppCommand::Hint` variant (no fields — no topic arg) in
+ `src/dsl/command.rs:544`.
+- `pub static HINT: CommandNode` in `grammar/app.rs` mirroring `HELP` but
+ with **no topic shape** (bare keyword, like `UNDO`): `entry:
+ Word::keyword("hint")`, `shape: EMPTY_SEQ` (as `UNDO`,
+ `grammar/app.rs:333`), `ast_builder:
+ build_hint` (returns `Command::App(AppCommand::Hint)`), `help_id:
+ Some("app.hint")`, `hint_id: Some("app.hint")`, `usage_ids:
+ &["parse.usage.hint"]`.
+- Register `(&app::HINT, CommandCategory::Simple)` in `REGISTRY`
+ (`grammar/mod.rs`), beside `HELP`. (App commands are available in both
+ modes via the existing mechanism.)
+
+## 4. Command identification (live-input → node)
+
+The F1 live-input path needs "which command form is being typed." **The
+lookup machinery already exists** — do not rebuild entry matching:
+- `command_for_entry_word(word) -> Option<(usize, &'static CommandNode)>`
+ (`grammar/mod.rs:811`) returns the matched node for an entry word
+ (Simple-first; the caller extracts the first word of the input).
+- `usage_keys_for_input_in_mode(source, mode)` (`grammar/mod.rs:564`)
+ already performs the **mode-aware** Simple/Advanced selection the hint
+ path needs (advanced `create` → the SQL nodes, simple → the DSL node) —
+ it just returns `usage_ids` rather than the node.
+- **The only new bit:** a thin `hint_id_for_input_in_mode(source, mode)`
+ (or a node-returning sibling of `usage_keys_for_input_in_mode`) that
+ applies the same mode selection and returns the chosen node's
+ `hint_id`. Mirror the existing function; don't duplicate its matching.
+- **`:`-strip:** in Simple mode, strip a leading `:` (one-shot escape,
+ ADR-0003) before identification so `: SELECT …` resolves to the
+ advanced `SELECT` node.
+- No match (empty / unrecognised entry word) → the "getting started"
+ pointer (D2).
+
+## 5. F1 keybinding (read-only overlay)
+
+In `App::handle_key` (`src/app.rs:1155`):
+- Add an F1 arm (`KeyCode::F(1)`) **after** the modal gate and the
+ sidebar-nav gate (inert there, per D2), and **before** the
+ "any other key clears the completion memo" fall-through (`_ =>
+ self.last_completion = None`, ~line 1228) — F1 must **not** clear the
+ memo or touch the buffer/cursor (D1).
+- Behaviour (the trigger matrix, D2):
+ - non-empty input → `note_hint_for_input()` (the command's `hint.cmd`
+ block + the live "Next:" expected-set from the walker).
+ - empty input + `last_error_hint_key` set → `note_hint_for_error()`.
+ - empty input + no recent error → `note_getting_started()`.
+- Returns `Vec::new()` (pure output emission, like `help`).
+- `demo_badge_label` (`app.rs:520`) gains an `F1 → "[F1]"` entry so demo
+ mode surfaces it (ADR-0047).
+
+## 6. The two error routes (D2 / D5)
+
+- **Runtime errors:** add `last_error_hint_key: Option` to `App`.
+ Set it where friendly errors are rendered (`runtime.rs:2615`,
+ `app.rs:2424`) from the error's class key; clear on the next successful
+ command. The `hint` command and empty-input F1 read it.
+- **Pre-submit diagnostics:** the F1 live-input path, when the input
+ carries an under-cursor diagnostic, reads it straight from the walker
+ (`input_diagnostics_in_mode`, the same source the ambient panel uses)
+ and renders that diagnostic's `hint.err.` block instead of (or
+ alongside) the command block. No stored state.
+- Both render from `hint.err.*`.
+
+## 7. Rendering: the `note_hint*` family (D4)
+
+- New `App::note_hint_for_input`, `note_hint_for_error`,
+ `note_getting_started` (siblings of `note_help`/`note_help_topic`,
+ `app.rs:2982`/`3021`).
+- A tier-3 block is **structured** (`what` / `example` / `concept`, plus
+ the live `Next:` line on the input path). The catalogue stores each part
+ under sub-keys (`hint.cmd..what`, `.example`, `.concept`); the
+ renderer fetches each via `t!` and lays them out as a small framed
+ block.
+- Styling: `OutputKind::System`; `OutputStyleClass::Hint` (muted) on
+ `what`/`concept`/`Next`, `Neutral` on `example` so the runnable line
+ stands out. Reuse `OutputLine::styled` + `push_category_three_prose`
+ patterns (`app.rs:3121`).
+- **Fallback:** if a node's `hint_id` is `None` or a key is missing,
+ degrade to tier-2 (ambient prose for the input path; the verbose error
+ `hint:` for the error path) — never blank.
+
+## 8. Catalogue + `keys.rs`
+
+- New sub-namespaces under the existing top-level `hint:` in
+ `src/friendly/strings/en-US.yaml`: `hint.cmd..{what,example,
+ concept}` and `hint.err..{what,example,concept}`.
+- Register every key + its placeholders in `src/friendly/keys.rs`
+ (`KEYS_AND_PLACEHOLDERS`) so the build-time validation covers them.
+- `parse.usage.hint` + `help.app.hint` strings for the command itself.
+
+## 9. Content (Phase C — the bulk, batched per D7)
+
+Exemplars approved in the ADR (`insert` live-input, FK child-side error,
+`add relationship`) are the template. Author in reviewable batches:
+1. **App commands** (~16): save/save as/load/new/rebuild/export/import/
+ replay/undo/redo/mode/messages/copy/help/hint/quit.
+2. **DDL** (simple): create table, create m:n, add column/relationship/
+ index, drop, rename, change column.
+3. **DML** (simple): insert, update, delete, show, seed, explain,
+ select/with.
+4. **Advanced-mode SQL forms** (7): SQL CREATE TABLE, ALTER TABLE,
+ CREATE/DROP INDEX, DROP TABLE, SQL INSERT/UPDATE/DELETE, EXPLAIN SQL —
+ **own blocks, SQL-syntax examples**.
+5. **Runtime error classes** (9): unique, foreign_key ×{child,parent},
+ not_null, check, type_mismatch, not_found, already_exists, generic,
+ invalid_value.
+6. **`diagnostic.*` classes** (~33): arity/type/unknown-table-column/etc.
+
+Each block: `what` (1–2 sentences), `example` (one runnable line,
+mode-correct), `concept` (the relational idea — the teaching part;
+optional only where genuinely none, e.g. `quit`).
+
+## 10. Tests
+
+Written test-first against the Phase-A skeleton where possible.
+
+- **Tier 1 (unit, `app.rs`):**
+ - trigger matrix: F1 non-empty → command block; F1 empty + recent error
+ → error block; F1 empty + none → getting-started; `hint` command +
+ error → error block; `hint` + none → getting-started.
+ - `last_error_hint_key` set on a failing command, cleared on the next
+ success.
+ - routing: a pre-submit diagnostic on the input drives the diagnostic
+ `hint.err`; a runtime error drives the stored-key route.
+ - `:`-strip: `: SELECT …` in Simple mode resolves to the advanced node.
+ - **read-only overlay:** F1 leaves `input`, `input_cursor`, and
+ `last_completion` unchanged.
+ - tier-2 fallback when `hint_id`/key absent.
+- **Tier 2 (`insta`):** snapshot a representative rendered tier-3 block
+ (the `insert` exemplar) so the framed layout + styling spans are locked.
+- **Tier 3 (integration, `tests/it/`):** type a partial command → F1 →
+ block appears, buffer untouched; run a failing insert → `hint` → FK
+ error expansion.
+- **Comprehensiveness coverage test** (enforces D6, the key one): iterate
+ `REGISTRY` and assert every node has a `hint_id` resolving to a
+ `hint.cmd.*` block; assert every runtime-error + `diagnostic.*` class
+ has a `hint.err.*` block. **Red until Phase C completes** — enable
+ (un-`ignore`) as the final gate.
+- `keys.rs` validation continues to guarantee every *referenced* key
+ resolves.
+
+## 11. Keybinding strip + discoverability (Phase D)
+
+- The ADR-0051 bottom strip advertises **F1 = hint** in the editing/
+ typing state (and on the empty-input state, since F1 still does
+ something there). Re-accept the affected full-panel snapshots.
+
+## 12. ADR / docs
+
+- ADR-0053 is committed (`e16ad50`). On completion, flip its Status from
+ "implementation pending" to implemented (with date), and update the
+ README index entry + `requirements.md` **H2 → [x]** and **A1 → [x]**
+ (A1 closes when `hint` lands).
+
+## 13. Risks / watch-list
+
+- **Command-identification reuse.** The lookup exists
+ (`command_for_entry_word` + the mode-aware `usage_keys_for_input_in_mode`,
+ `grammar/mod.rs:811`/`564`); the only new code is a thin node/`hint_id`
+ variant that reuses their selection. Do **not** re-implement entry-word
+ matching — mirror the existing functions.
+- **Structured-key ergonomics.** Three sub-keys per block × ~80 blocks is
+ ~240 catalogue keys; keep the `keys.rs` registration generation tidy
+ (consider a helper that registers the `{what,example,concept}` triple
+ for an id).
+- **Content voice drift across batches.** Re-check each batch against the
+ approved exemplars; the `concept` line is where drift (too terse / too
+ advanced) creeps in. Pedagogy wins ties.
+- **F1 terminal capture.** A few terminals intercept F1; acceptable
+ (it's the convention) but note it if testing surfaces it.
+- **Snapshot churn.** The strip change re-accepts ADR-0051 snapshots;
+ keep that diff isolated.
+- **Coverage-test timing.** It is red through Phases A–C; gate it so CI
+ isn't broken mid-stream (e.g. `#[ignore]` until the final batch), then
+ make passing it the completion criterion.
+```
diff --git a/docs/requirements.md b/docs/requirements.md
index 2222f11..d3fc046 100644
--- a/docs/requirements.md
+++ b/docs/requirements.md
@@ -147,11 +147,19 @@ since ADR-0027.)
cursor editing and is complete on its own terms; the separate
**multi-line** entry goal is tracked under I1, which is
genuinely not started.)*
-- [ ] **I1b** Readline-style cursor shortcuts: Ctrl-A / Ctrl-E
+- [x] **I1b** Readline-style cursor shortcuts: Ctrl-A / Ctrl-E
as aliases for Home / End for users on keyboards without those
keys (and for ergonomics in command-driven workflows). Likely
followed by Ctrl-W (delete previous word), Ctrl-K (delete to
- end), Ctrl-U (delete to start). Pending.
+ end), Ctrl-U (delete to start).
+ *(Done 2026-06-12 — ADR-0049, issue #29: the full set —
+ Esc-clear + Ctrl-A/E/W/K/U — wired in `App::handle_key`
+ (`src/app.rs`) with helpers `clear_input` / `delete_prev_word`
+ / `kill_to_end` / `kill_to_start`; Esc clears only when no
+ completion memo is alive (the memo wins first, ADR-0022);
+ cursor-only keys leave history navigation intact, kill keys
+ end it; 22 Tier-1 tests. On-screen advertisement of these keys
+ is issue #27's bottom-status-line work.)*
- [x] **I2** Persistent navigable input history (project-scoped).
*(Implemented across Iterations 2 + 6: per-command append to
`history.log` (Iter 2); on project open, the in-memory
@@ -696,7 +704,10 @@ since ADR-0027.)
`Generator`, and full completion / highlight / validity / help /
parse-error-pedagogy wiring. Deferred SD2 increments:
user-defined custom generators, NULL injection, multi-locale,
- recursive parent auto-seed.)*
+ recursive parent auto-seed. Later catalogue refinements:
+ **#33** year-as-int (`year`/`*_year`/`published`/`founded`) and
+ **#34** conventional choice sets (`priority`/`severity`/`rating`,
+ `status` excluded) — ADR-0048 Amendment 1.)*
## Query analysis
diff --git a/src/action.rs b/src/action.rs
index 2ca793b..b115ddb 100644
--- a/src/action.rs
+++ b/src/action.rs
@@ -41,6 +41,11 @@ pub enum Action {
/// §4). `source` is the original user-typed text.
JournalFailure {
source: String,
+ /// Whether the failed submission was advanced (ADR-0052): tags the
+ /// `err` record `err:adv` so a failed advanced command hydrates in
+ /// its `:`-prefixed form, recallable in simple mode. App commands
+ /// (mode-agnostic) are `false`.
+ advanced: bool,
},
/// User issued the `rebuild` app-level command (ADR-0015
/// §7, §11). Runtime computes a summary from
diff --git a/src/app.rs b/src/app.rs
index 2863382..cf22c16 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -271,6 +271,13 @@ pub struct App {
pub nav_focus: NavFocus,
pub output: VecDeque,
pub hint: Option,
+ /// Catalog class key of the most recent runtime error (H2 /
+ /// ADR-0053 D5), e.g. `foreign_key.child_side`. Set when a
+ /// friendly error is rendered, cleared on the next successful
+ /// command. The submitted `hint` command and empty-input F1 use
+ /// it to render that error's tier-3 `hint.err.` block.
+ /// `None` → no recent error → the "getting started" pointer.
+ pub last_error_hint_key: Option,
/// The validity indicator's currently-visible verdict
/// (ADR-0027). `None` means the indicator shows nothing —
/// the input is clean, or it is hidden mid-typing while the
@@ -521,6 +528,7 @@ pub const fn demo_badge_label(key: &KeyEvent) -> Option<&'static str> {
match (key.code, key.modifiers) {
(KeyCode::Tab, _) => Some("[TAB]"),
(KeyCode::BackTab, _) => Some("[SHIFT-TAB]"),
+ (KeyCode::F(1), _) => Some("[F1]"),
(KeyCode::Enter, _) => Some("[ENTER]"),
(KeyCode::Esc, _) => Some("[ESC]"),
(KeyCode::Up, _) => Some("[UP]"),
@@ -557,6 +565,7 @@ impl App {
nav_focus: NavFocus::Input,
output: VecDeque::with_capacity(OUTPUT_CAPACITY),
hint: None,
+ last_error_hint_key: None,
input_indicator: None,
tables: Vec::new(),
relationships: Vec::new(),
@@ -646,6 +655,17 @@ impl App {
}
}
+ /// Whether the user is currently browsing a recalled history entry
+ /// (Up/Down navigation, unedited). Exposes the private
+ /// `history_cursor` predicate so the context-aware status strip
+ /// (ADR-0051) can select its history-navigation state. Editing the
+ /// recalled line ends navigation (`cancel_history_navigation`), so
+ /// this is `false` again the moment the user types.
+ #[must_use]
+ pub const fn is_browsing_history(&self) -> bool {
+ self.history_cursor.is_some()
+ }
+
/// The input view the **live-feedback** walkers (completion, ambient
/// hint, validity verdict, highlight overlays) should see, plus the
/// byte offset stripped from the front and the cursor mapped into the
@@ -863,13 +883,16 @@ impl App {
error,
facts,
source,
+ advanced,
} => {
self.handle_dsl_failure(&command, error, facts);
// ADR-0034 §1/§2: an execution failure is journalled
// `err` so it is recallable across sessions (the
// worker only journals successful commands). The App
- // emits the intent; the runtime does the append.
- vec![Action::JournalFailure { source }]
+ // emits the intent; the runtime does the append. The
+ // mode rides along (ADR-0052) so an advanced failure
+ // tags `err:adv`.
+ vec![Action::JournalFailure { source, advanced }]
}
AppEvent::TablesRefreshed(tables) => {
trace!(count = tables.len(), "tables refreshed");
@@ -1194,6 +1217,21 @@ impl App {
return self.handle_nav_key(key);
}
+ // H2 / ADR-0053: F1 is a read-only contextual-hint overlay —
+ // it emits into the output journal and must NOT touch the input
+ // buffer, cursor, or the completion memo, so it sits ahead of
+ // the memo-clearing completion match below. Non-empty input →
+ // a hint for the command being typed; empty input → expand on
+ // the most recent error (or a getting-started pointer).
+ if key.code == KeyCode::F(1) {
+ if self.input.trim().is_empty() {
+ self.note_hint_for_recent_error();
+ } else {
+ self.note_hint_for_input();
+ }
+ return Vec::new();
+ }
+
// ADR-0022 stage 8 — non-modal completion. Tab /
// Shift-Tab cycle; Esc / Backspace undo the whole
// last-Tab insertion in one keystroke while the memo
@@ -1217,6 +1255,13 @@ impl App {
match (key.code, key.modifiers) {
(KeyCode::Char('c'), KeyModifiers::CONTROL) => vec![Action::Quit],
(KeyCode::Enter, _) => self.submit(),
+ // ADR-0049 (issue #29): Esc clears a partly-typed command.
+ // Reached only when no completion memo is alive — the memo
+ // block above consumes Esc first to undo a completion.
+ (KeyCode::Esc, _) => {
+ self.clear_input();
+ Vec::new()
+ }
(KeyCode::Up, _) => {
self.history_back();
Vec::new()
@@ -1233,11 +1278,15 @@ impl App {
self.cursor_right();
Vec::new()
}
- (KeyCode::Home, _) => {
+ // ADR-0049: Ctrl-A / Ctrl-E are readline aliases for
+ // Home / End — line start / end — for keyboards without
+ // those keys. Cursor-only, so (like Home/End) they do not
+ // cancel history navigation.
+ (KeyCode::Home, _) | (KeyCode::Char('a'), KeyModifiers::CONTROL) => {
self.input_cursor = 0;
Vec::new()
}
- (KeyCode::End, _) => {
+ (KeyCode::End, _) | (KeyCode::Char('e'), KeyModifiers::CONTROL) => {
self.input_cursor = self.input.len();
Vec::new()
}
@@ -1251,6 +1300,23 @@ impl App {
self.delete_at_cursor();
Vec::new()
}
+ // ADR-0049: readline kill shortcuts. Each mutates the
+ // buffer, so each ends history navigation like Backspace.
+ (KeyCode::Char('w'), KeyModifiers::CONTROL) => {
+ self.cancel_history_navigation();
+ self.delete_prev_word();
+ Vec::new()
+ }
+ (KeyCode::Char('k'), KeyModifiers::CONTROL) => {
+ self.cancel_history_navigation();
+ self.kill_to_end();
+ Vec::new()
+ }
+ (KeyCode::Char('u'), KeyModifiers::CONTROL) => {
+ self.cancel_history_navigation();
+ self.kill_to_start();
+ Vec::new()
+ }
(KeyCode::PageUp, _) => {
self.scroll_output_up();
Vec::new()
@@ -1545,6 +1611,54 @@ impl App {
self.input.replace_range(self.input_cursor..idx, "");
}
+ /// Esc — clear a partly-typed command (ADR-0049). Empties the
+ /// buffer, parks the cursor at the start, drops any horizontal
+ /// scroll, and ends history navigation (the cleared line *is* the
+ /// new draft). Only reached when no completion memo is alive — Esc
+ /// undoes a live completion first (handle_key precedence).
+ fn clear_input(&mut self) {
+ self.cancel_history_navigation();
+ self.input.clear();
+ self.input_cursor = 0;
+ self.input_scroll_offset = 0;
+ }
+
+ /// Ctrl-W — delete the word before the cursor (ADR-0049). Eats any
+ /// run of trailing whitespace, then the preceding run of
+ /// non-whitespace, readline-style. UTF-8 safe: word boundaries are
+ /// found on char boundaries, so multi-byte words delete cleanly.
+ fn delete_prev_word(&mut self) {
+ if self.input_cursor == 0 {
+ return;
+ }
+ let prefix = &self.input[..self.input_cursor];
+ // Strip trailing whitespace, then locate the start of the
+ // word that now ends the prefix.
+ let after_ws = prefix.trim_end_matches(char::is_whitespace);
+ // `idx` is the byte offset of the last whitespace char before
+ // the word; the word starts at the next char. No whitespace at
+ // all → the word starts at the buffer start.
+ let start = after_ws.rfind(char::is_whitespace).map_or(0, |idx| {
+ idx + after_ws[idx..].chars().next().map_or(0, char::len_utf8)
+ });
+ self.input.replace_range(start..self.input_cursor, "");
+ self.input_cursor = start;
+ }
+
+ /// Ctrl-K — kill from the cursor to the end of the line (ADR-0049).
+ /// The cursor is always a char boundary, so a plain truncate is
+ /// safe.
+ fn kill_to_end(&mut self) {
+ self.input.truncate(self.input_cursor);
+ }
+
+ /// Ctrl-U — kill from the start of the line to the cursor
+ /// (ADR-0049). The cursor moves to the start.
+ fn kill_to_start(&mut self) {
+ self.input.replace_range(0..self.input_cursor, "");
+ self.input_cursor = 0;
+ }
+
/// Move backwards in history (towards older entries).
fn history_back(&mut self) {
if self.history.is_empty() {
@@ -1561,11 +1675,27 @@ impl App {
Some(i) => i - 1,
};
self.history_cursor = Some(next_index);
- self.input = self.history[next_index].clone();
+ let stored = self.history[next_index].clone();
+ self.input = self.recall_display(&stored);
self.input_cursor = self.input.len();
self.input_scroll_offset = 0;
}
+ /// The display form of a stored history entry for the current mode
+ /// (ADR-0052, issue #30). An advanced entry is stored in its
+ /// `:`-prefixed simple-mode runnable form; in **advanced** mode the
+ /// `:` is stripped so it runs as bare SQL, while in **simple** mode it
+ /// stays prefixed and runs via the one-shot escape. A simple entry
+ /// (never starting with `:`) is returned unchanged in either mode.
+ fn recall_display(&self, stored: &str) -> String {
+ if self.mode == Mode::Advanced
+ && let Some(rest) = stored.strip_prefix(':')
+ {
+ return rest.trim_start().to_string();
+ }
+ stored.to_string()
+ }
+
/// Move forwards in history (towards newer entries; eventually
/// returning to the user's saved draft).
fn history_forward(&mut self) {
@@ -1574,7 +1704,8 @@ impl App {
};
if i + 1 < self.history.len() {
self.history_cursor = Some(i + 1);
- self.input = self.history[i + 1].clone();
+ let stored = self.history[i + 1].clone();
+ self.input = self.recall_display(&stored);
} else {
// Past the most recent entry — restore the draft and
// exit navigation mode.
@@ -1622,10 +1753,6 @@ impl App {
if trimmed.is_empty() {
return Vec::new();
}
- // Record the original (trimmed) line in history regardless
- // of whether it parses, so users can recall and edit
- // typo'd commands.
- self.push_history(trimmed);
// `:` one-shot escape: in simple mode, a leading `:` means
// treat *this single submission* as advanced. The persistent
@@ -1642,6 +1769,9 @@ impl App {
};
if effective_input.is_empty() {
+ // A bare `:` (one-shot with nothing after it) executes
+ // nothing and is not recorded — the push moved below the
+ // strip (ADR-0052), so it no longer lands in history.
return Vec::new();
}
@@ -1652,16 +1782,38 @@ impl App {
"submit"
);
- // Parse-first: app-level commands and DSL commands now
- // share the chumsky parser (per the round-5 refactor).
- // App commands work in both modes — they're not gated by
- // `effective_mode`. Anything that parses to a non-App
- // variant falls through to the existing mode-specific
- // path: simple → DSL execution; advanced → SQL placeholder.
- // Anything that fails to parse falls through too — the
- // simple-mode path renders the friendly parse error, the
- // advanced-mode path renders the SQL placeholder.
- if let Ok(Command::App(app_cmd)) = parse_command(&effective_input) {
+ // Parse-first: app-level commands and DSL commands share the
+ // parser. App commands work in both modes — they're not gated by
+ // `effective_mode`. Anything that parses to a non-App variant (or
+ // fails to parse) falls through to the mode-specific path.
+ let parsed = parse_command(&effective_input);
+
+ // ADR-0052 (issue #30): record the command for cross-mode recall.
+ // An **advanced** (SQL) command is stored in its `:`-prefixed
+ // simple-mode runnable form, so it can be recalled and re-run in
+ // simple mode (recall strips the `:` again in advanced mode). A
+ // simple command — and **any app command**, which runs in either
+ // mode and so must not gain a `:` — is stored bare. Recorded
+ // regardless of whether it parses, so typo'd commands stay
+ // recallable. The canonical (un-prefixed) text is what reaches
+ // the journal via `ExecuteDsl.source`.
+ let is_app = matches!(&parsed, Ok(Command::App(_)));
+ // H2 / ADR-0053 D5: a new *DSL* command supersedes the previous
+ // runtime error for `hint`. App commands (incl. `hint` itself)
+ // and parse errors leave it intact, so `hint` still expands the
+ // last real error after, say, a `help` in between.
+ if matches!(&parsed, Ok(cmd) if !matches!(cmd, Command::App(_))) {
+ self.last_error_hint_key = None;
+ }
+ let advanced = submission_mode.is_advanced() && !is_app;
+ let ring_line = if advanced {
+ format!(": {effective_input}")
+ } else {
+ effective_input.clone()
+ };
+ self.push_history(&ring_line);
+
+ if let Ok(Command::App(app_cmd)) = parsed {
return self.dispatch_app_command(app_cmd, &effective_input);
}
@@ -1693,6 +1845,13 @@ impl App {
}
Vec::new()
}
+ // H2 / ADR-0053: a submitted `hint` acts on the most recent
+ // runtime error (the buffer is empty post-submit). The
+ // live-input surface is the F1 keybinding (handle_key).
+ AppCommand::Hint => {
+ self.note_hint_for_recent_error();
+ Vec::new()
+ }
AppCommand::Rebuild => vec![Action::PrepareRebuild],
AppCommand::Save => self.handle_save_command(false),
AppCommand::SaveAs => self.handle_save_command(true),
@@ -1874,6 +2033,7 @@ impl App {
self.note_error(note);
return vec![Action::JournalFailure {
source: input.to_string(),
+ advanced: submission_mode.is_advanced(),
}];
}
// Issue #17: simple-mode (DSL) counterpart. A wrong-count
@@ -1901,6 +2061,7 @@ impl App {
self.note_error(render_usage_block(input, mode));
return vec![Action::JournalFailure {
source: input.to_string(),
+ advanced: submission_mode.is_advanced(),
}];
}
self.push_output(OutputLine::echo(input, mode));
@@ -1987,6 +2148,7 @@ impl App {
// append; the App only emits the intent.
vec![Action::JournalFailure {
source: input.to_string(),
+ advanced: submission_mode.is_advanced(),
}]
}
}
@@ -2034,8 +2196,10 @@ impl App {
// ADR-0044 §1 "relationship-relevant" reach: when a
// relationship is the subject of the command (`show table`,
// `add`/`drop relationship`), render the table's
- // relationships as compact diagrams; every other DDL echo
- // keeps the prose `References:` / `Referenced by:` form.
+ // relationships as compact diagrams. Every other (incidental
+ // DDL) echo renders structure only — no relationship block
+ // at all (ADR-0050, issue #28; supersedes ADR-0044 §1's
+ // prose retention for these surfaces).
if matches!(
command,
Command::ShowTable { .. }
@@ -2296,6 +2460,10 @@ impl App {
// runtime built before posting the event.
let ctx = self.build_translate_context(command, facts);
let rendered = crate::friendly::translate_error(&error, &ctx).render();
+ // H2 / ADR-0053 D5: remember this error's tier-3 class so a
+ // following `hint` (or empty-input F1) can expand on it.
+ self.last_error_hint_key =
+ crate::friendly::error_hint_class(&error, &ctx).map(String::from);
warn!(
verb = command.verb(),
error = %rendered,
@@ -2965,6 +3133,94 @@ impl App {
}
}
+ // ── H2 / ADR-0053: contextual `hint` ────────────────────────
+ // Phase A wires the two surfaces (F1 → live input; the `hint`
+ // command → most recent error) plus the tier-2 fallback. The
+ // tier-3 corpus (`hint.cmd.*` / `hint.err.*`) is authored in later
+ // phases; until a block exists, `emit_tier3_block` returns false
+ // and the surface degrades to the ambient prose / getting-started
+ // pointer — never blank.
+
+ /// F1 with a non-empty buffer: a tier-3 hint for the command form
+ /// being typed, else the tier-2 ambient prose (ADR-0053 D2).
+ /// Read-only — callers guarantee the buffer/cursor/memo are left
+ /// untouched.
+ fn note_hint_for_input(&mut self) {
+ // `feedback_view` strips the `:` one-shot sigil and
+ // `effective_mode` reflects the one-shot advanced surface, so
+ // the hint matches the command the user is actually typing.
+ let (view, cursor, _off) = self.feedback_view();
+ let probe = view.to_string();
+ let mode = self.effective_mode().as_mode();
+ if let Some(id) = crate::dsl::grammar::hint_key_for_input_in_mode(&probe, mode)
+ && self.emit_tier3_block(&format!("hint.cmd.{id}"))
+ {
+ return;
+ }
+ // Tier-2 fallback: surface the ambient prose as a persistent
+ // line (computed exactly as the live panel does).
+ let ambient = crate::input_render::ambient_hint_in_mode(
+ &probe,
+ cursor,
+ self.last_completion.as_ref(),
+ &self.schema_cache,
+ mode,
+ );
+ match ambient {
+ Some(crate::input_render::AmbientHint::Prose(text)) => {
+ self.push_category_three_prose(text);
+ }
+ Some(crate::input_render::AmbientHint::Candidates { items, .. }) => {
+ let names = items
+ .iter()
+ .map(|c| c.text.clone())
+ .collect::>()
+ .join(", ");
+ self.push_category_three_prose(crate::t!("hint.ambient_expected", expected = names));
+ }
+ None => self.note_getting_started(),
+ }
+ }
+
+ /// The `hint` command (and empty-input F1): expand on the most
+ /// recent runtime error, else point the user at how to start
+ /// (ADR-0053 D2/D5).
+ fn note_hint_for_recent_error(&mut self) {
+ if let Some(class) = self.last_error_hint_key.clone()
+ && self.emit_tier3_block(&format!("hint.err.{class}"))
+ {
+ return;
+ }
+ self.note_getting_started();
+ }
+
+ fn note_getting_started(&mut self) {
+ self.note_system(crate::t!("hint.getting_started"));
+ }
+
+ /// Render a tier-3 block (`.what` / `.example` / `.concept`)
+ /// when it has been authored; returns `false` if the `what` part is
+ /// absent so the caller can fall back to tier 2. `what` is
+ /// mandatory, `example`/`concept` optional (ADR-0053 D3). Styling
+ /// polish (the framed block) lands with the corpus.
+ fn emit_tier3_block(&mut self, stem: &str) -> bool {
+ let cat = crate::friendly::catalog();
+ if cat.get(&format!("{stem}.what")).is_none() {
+ return false;
+ }
+ self.note_system(crate::friendly::translate(&format!("{stem}.what"), &[]));
+ if cat.get(&format!("{stem}.example")).is_some() {
+ self.note_system(crate::friendly::translate(&format!("{stem}.example"), &[]));
+ }
+ if cat.get(&format!("{stem}.concept")).is_some() {
+ self.push_category_three_prose(crate::friendly::translate(
+ &format!("{stem}.concept"),
+ &[],
+ ));
+ }
+ true
+ }
+
fn note_system(&mut self, text: impl Into) {
self.push_multiline(text.into(), OutputKind::System);
}
@@ -5404,6 +5660,7 @@ mod tests {
},
facts: crate::friendly::FailureContext::default(),
source: String::new(),
+ advanced: false,
});
let last = app.output.back().unwrap();
assert_eq!(last.kind, OutputKind::Error);
@@ -5412,6 +5669,186 @@ mod tests {
assert!(last.text.contains("Ghost"), "{}", last.text);
}
+ // ── H2 / ADR-0053: contextual `hint` (Phase A skeleton) ──────
+
+ fn f1(app: &mut App) -> Vec {
+ app.update(key(KeyCode::F(1)))
+ }
+
+ fn no_such_table_failure() -> AppEvent {
+ AppEvent::DslFailed {
+ command: Command::DropTable {
+ name: "Ghost".to_string(),
+ },
+ error: crate::db::DbError::Sqlite {
+ message: "no such table: Ghost".to_string(),
+ kind: crate::db::SqliteErrorKind::NoSuchTable,
+ },
+ facts: crate::friendly::FailureContext::default(),
+ source: String::new(),
+ advanced: false,
+ }
+ }
+
+ #[test]
+ fn hint_command_parses_to_app_hint() {
+ use crate::dsl::{parse_command, AppCommand, Command};
+ assert!(matches!(
+ parse_command("hint"),
+ Ok(Command::App(AppCommand::Hint))
+ ));
+ }
+
+ #[test]
+ fn hint_command_with_no_recent_error_shows_getting_started() {
+ let mut app = App::new();
+ type_str(&mut app, "hint");
+ submit(&mut app);
+ assert!(output_contains(&app, "press F1"), "{}", error_lines(&app));
+ }
+
+ #[test]
+ fn f1_on_empty_input_with_no_error_shows_getting_started() {
+ let mut app = App::new();
+ let before = app.output.len();
+ f1(&mut app);
+ assert!(app.output.len() > before, "F1 must emit something");
+ assert!(output_contains(&app, "press F1"));
+ }
+
+ #[test]
+ fn f1_is_a_read_only_overlay() {
+ let mut app = App::new();
+ type_str(&mut app, "insert into T");
+ let input = app.input.clone();
+ let cursor = app.input_cursor;
+ let before = app.output.len();
+ f1(&mut app);
+ assert_eq!(app.input, input, "F1 must not change the buffer");
+ assert_eq!(app.input_cursor, cursor, "F1 must not move the cursor");
+ assert!(app.output.len() > before, "F1 emits a hint line");
+ }
+
+ #[test]
+ fn f1_preserves_the_completion_memo() {
+ let mut app = App::new();
+ type_str(&mut app, "show ");
+ app.update(key(KeyCode::Tab));
+ assert!(app.last_completion.is_some(), "precondition: Tab sets the memo");
+ let input = app.input.clone();
+ f1(&mut app);
+ assert!(app.last_completion.is_some(), "F1 must not clear the memo");
+ assert_eq!(app.input, input, "F1 must not change the buffer");
+ }
+
+ #[test]
+ fn dsl_failure_sets_hint_class_and_a_later_dsl_command_clears_it() {
+ let mut app = App::new();
+ app.update(no_such_table_failure());
+ assert_eq!(app.last_error_hint_key.as_deref(), Some("not_found"));
+ // A new DSL command supersedes the previous error.
+ type_str(&mut app, "drop table Ghost");
+ submit(&mut app);
+ assert_eq!(app.last_error_hint_key, None);
+ }
+
+ #[test]
+ fn app_command_does_not_clear_the_hint_class() {
+ let mut app = App::new();
+ app.update(no_such_table_failure());
+ assert_eq!(app.last_error_hint_key.as_deref(), Some("not_found"));
+ // `help` (an app command) leaves the last error intact, so a
+ // following `hint` still expands on it.
+ type_str(&mut app, "help");
+ submit(&mut app);
+ assert_eq!(
+ app.last_error_hint_key.as_deref(),
+ Some("not_found"),
+ "an app command must not clear the last error's hint class"
+ );
+ }
+
+ #[test]
+ fn hint_after_error_emits_a_hint_without_panicking() {
+ // Phase A: no tier-3 `hint.err.*` content exists yet, so the
+ // error path falls back to the getting-started pointer. (Phase C
+ // replaces this with the real error block.)
+ let mut app = App::new();
+ app.update(no_such_table_failure());
+ let before = app.output.len();
+ type_str(&mut app, "hint");
+ submit(&mut app);
+ assert!(app.output.len() > before, "hint must emit something");
+ }
+
+ #[test]
+ fn help_list_includes_hint() {
+ let mut app = App::new();
+ type_str(&mut app, "help");
+ submit(&mut app);
+ assert!(
+ output_contains(&app, "explain the most recent error"),
+ "help list must advertise the hint command"
+ );
+ }
+
+ #[test]
+ fn help_hint_describes_the_hint_command() {
+ let mut app = App::new();
+ type_str(&mut app, "help hint");
+ submit(&mut app);
+ assert!(output_contains(&app, "explain the most recent error"));
+ }
+
+ // ── Phase B: tier-3 exemplar content renders ────────────────
+
+ #[test]
+ fn f1_on_insert_input_renders_the_insert_hint_block() {
+ let mut app = App::new();
+ type_str(&mut app, "insert into Customers ");
+ f1(&mut app);
+ assert!(
+ output_contains(&app, "Add one or more rows to a table"),
+ "expected the insert tier-3 block"
+ );
+ }
+
+ #[test]
+ fn f1_on_add_relationship_renders_the_relationship_block() {
+ let mut app = App::new();
+ type_str(&mut app, "add 1:n relationship from Customers.id to Orders.cust ");
+ f1(&mut app);
+ assert!(
+ output_contains(&app, "one parent, many children"),
+ "expected the add-relationship tier-3 block"
+ );
+ }
+
+ #[test]
+ fn f1_on_add_column_does_not_render_the_relationship_block() {
+ // Per-form disambiguation (ADR-0053 D3): `add column` resolves
+ // to `add_column` (no tier-3 block yet → tier-2 fallback), NOT
+ // the relationship block — proving the multi-form node keys
+ // per form, not per node.
+ let mut app = App::new();
+ type_str(&mut app, "add column Note text to Customers");
+ f1(&mut app);
+ assert!(!output_contains(&app, "one parent, many children"));
+ assert!(!output_contains(&app, "1:n"));
+ }
+
+ #[test]
+ fn hint_renders_the_foreign_key_error_block() {
+ let mut app = App::new();
+ app.last_error_hint_key = Some("foreign_key.child_side".to_string());
+ type_str(&mut app, "hint");
+ submit(&mut app);
+ assert!(
+ output_contains(&app, "doesn't match any parent row"),
+ "expected the FK child-side tier-3 block"
+ );
+ }
+
#[test]
fn messages_command_toggles_verbosity_and_reports() {
let mut app = App::new();
@@ -5462,6 +5899,7 @@ mod tests {
error: err,
facts,
source: String::new(),
+ advanced: false,
});
let body = app
.output
@@ -5511,6 +5949,7 @@ mod tests {
error: err,
facts,
source: String::new(),
+ advanced: false,
});
let body = app
.output
@@ -5543,6 +5982,7 @@ mod tests {
error: err(),
facts: crate::friendly::FailureContext::default(),
source: String::new(),
+ advanced: false,
});
let verbose_text = app
.output
@@ -5563,6 +6003,7 @@ mod tests {
error: err(),
facts: crate::friendly::FailureContext::default(),
source: String::new(),
+ advanced: false,
});
let short_text = app
.output
@@ -5756,6 +6197,245 @@ mod tests {
assert_eq!(app.input_cursor, 0);
}
+ // ---- ADR-0049 (issue #29): input-field readline keymap ----
+
+ fn ctrl(c: char) -> AppEvent {
+ key_mod(KeyCode::Char(c), KeyModifiers::CONTROL)
+ }
+
+ #[test]
+ fn esc_clears_a_partly_typed_command() {
+ let mut app = App::new();
+ type_str(&mut app, "drop table T");
+ app.update(key(KeyCode::Esc));
+ assert_eq!(app.input, "");
+ assert_eq!(app.input_cursor, 0);
+ }
+
+ #[test]
+ fn esc_clear_resets_horizontal_scroll() {
+ // A long line that has been horizontally scrolled must
+ // reset its scroll offset on clear, exactly like submit.
+ let mut app = App::new();
+ type_str(&mut app, "drop table T");
+ app.input_scroll_offset = 5;
+ app.update(key(KeyCode::Esc));
+ assert_eq!(app.input, "");
+ assert_eq!(app.input_scroll_offset, 0);
+ }
+
+ #[test]
+ fn esc_clear_cancels_history_navigation() {
+ let mut app = App::new();
+ type_str(&mut app, "drop table A");
+ submit(&mut app);
+ app.update(key(KeyCode::Up));
+ assert_eq!(app.input, "drop table A");
+ app.update(key(KeyCode::Esc));
+ assert_eq!(app.input, "");
+ assert!(app.history_cursor.is_none());
+ }
+
+ #[test]
+ fn esc_with_live_completion_memo_undoes_rather_than_clears() {
+ // Precedence: while a multi-candidate Tab memo is alive, Esc
+ // undoes the completion (restoring the original text), it does
+ // NOT clear the whole input.
+ let mut app = App::new();
+ type_str(&mut app, "show ");
+ app.update(key(KeyCode::Tab)); // → "show data", memo alive
+ assert!(app.last_completion.is_some());
+ app.update(key(KeyCode::Esc));
+ assert_eq!(app.input, "show ");
+ }
+
+ #[test]
+ fn ctrl_a_moves_cursor_to_start() {
+ let mut app = App::new();
+ type_str(&mut app, "hello");
+ app.update(ctrl('a'));
+ assert_eq!(app.input_cursor, 0);
+ }
+
+ #[test]
+ fn ctrl_e_moves_cursor_to_end() {
+ let mut app = App::new();
+ type_str(&mut app, "hello");
+ app.update(key(KeyCode::Home));
+ assert_eq!(app.input_cursor, 0);
+ app.update(ctrl('e'));
+ assert_eq!(app.input_cursor, 5);
+ }
+
+ #[test]
+ fn ctrl_w_deletes_the_previous_word() {
+ let mut app = App::new();
+ type_str(&mut app, "drop table T");
+ app.update(ctrl('w'));
+ assert_eq!(app.input, "drop table ");
+ assert_eq!(app.input_cursor, "drop table ".len());
+ }
+
+ #[test]
+ fn ctrl_w_eats_trailing_whitespace_then_the_word() {
+ let mut app = App::new();
+ type_str(&mut app, "foo bar ");
+ app.update(ctrl('w'));
+ assert_eq!(app.input, "foo ");
+ assert_eq!(app.input_cursor, 4);
+ }
+
+ #[test]
+ fn ctrl_w_at_start_is_a_noop() {
+ let mut app = App::new();
+ type_str(&mut app, "hello");
+ app.input_cursor = 0;
+ app.update(ctrl('w'));
+ assert_eq!(app.input, "hello");
+ assert_eq!(app.input_cursor, 0);
+ }
+
+ #[test]
+ fn ctrl_w_only_deletes_back_to_the_cursor() {
+ // Mid-line: deletes the word before the cursor, leaving the
+ // suffix untouched.
+ let mut app = App::new();
+ type_str(&mut app, "drop table T");
+ app.input_cursor = "drop table".len(); // cursor right after "table"
+ app.update(ctrl('w'));
+ assert_eq!(app.input, "drop T");
+ assert_eq!(app.input_cursor, "drop ".len());
+ }
+
+ #[test]
+ fn ctrl_w_handles_multibyte_words() {
+ let mut app = App::new();
+ type_str(&mut app, "héllo wörld");
+ app.update(ctrl('w'));
+ assert_eq!(app.input, "héllo ");
+ assert_eq!(app.input_cursor, "héllo ".len());
+ }
+
+ #[test]
+ fn ctrl_k_kills_to_end_of_line() {
+ let mut app = App::new();
+ type_str(&mut app, "hello world");
+ app.input_cursor = 5; // after "hello"
+ app.update(ctrl('k'));
+ assert_eq!(app.input, "hello");
+ assert_eq!(app.input_cursor, 5);
+ }
+
+ #[test]
+ fn ctrl_u_kills_to_start_of_line() {
+ let mut app = App::new();
+ type_str(&mut app, "hello world");
+ app.input_cursor = 6; // after "hello "
+ app.update(ctrl('u'));
+ assert_eq!(app.input, "world");
+ assert_eq!(app.input_cursor, 0);
+ }
+
+ #[test]
+ fn ctrl_u_cancels_history_navigation() {
+ let mut app = App::new();
+ type_str(&mut app, "drop table A");
+ submit(&mut app);
+ app.update(key(KeyCode::Up));
+ assert_eq!(app.input, "drop table A");
+ app.update(ctrl('u')); // cursor is at end after recall → clears all
+ assert_eq!(app.input, "");
+ assert!(app.history_cursor.is_none());
+ }
+
+ #[test]
+ fn ctrl_w_cancels_history_navigation() {
+ let mut app = App::new();
+ type_str(&mut app, "drop table A");
+ submit(&mut app);
+ app.update(key(KeyCode::Up));
+ assert_eq!(app.input, "drop table A");
+ app.update(ctrl('w')); // deletes the recalled "A" word
+ assert_eq!(app.input, "drop table ");
+ assert!(app.history_cursor.is_none());
+ }
+
+ #[test]
+ fn ctrl_w_on_whitespace_only_clears_it() {
+ let mut app = App::new();
+ type_str(&mut app, " ");
+ app.update(ctrl('w'));
+ assert_eq!(app.input, "");
+ assert_eq!(app.input_cursor, 0);
+ }
+
+ #[test]
+ fn ctrl_k_at_end_of_line_is_a_noop() {
+ let mut app = App::new();
+ type_str(&mut app, "hello");
+ // Cursor at end.
+ app.update(ctrl('k'));
+ assert_eq!(app.input, "hello");
+ assert_eq!(app.input_cursor, 5);
+ }
+
+ #[test]
+ fn ctrl_k_at_start_kills_the_whole_line() {
+ let mut app = App::new();
+ type_str(&mut app, "hello");
+ app.input_cursor = 0;
+ app.update(ctrl('k'));
+ assert_eq!(app.input, "");
+ assert_eq!(app.input_cursor, 0);
+ }
+
+ #[test]
+ fn ctrl_u_at_start_of_line_is_a_noop() {
+ let mut app = App::new();
+ type_str(&mut app, "hello");
+ app.input_cursor = 0;
+ app.update(ctrl('u'));
+ assert_eq!(app.input, "hello");
+ assert_eq!(app.input_cursor, 0);
+ }
+
+ #[test]
+ fn ctrl_u_at_end_kills_the_whole_line() {
+ let mut app = App::new();
+ type_str(&mut app, "hello");
+ // Cursor at end.
+ app.update(ctrl('u'));
+ assert_eq!(app.input, "");
+ assert_eq!(app.input_cursor, 0);
+ }
+
+ #[test]
+ fn esc_on_empty_input_is_harmless() {
+ let mut app = App::new();
+ app.update(key(KeyCode::Esc));
+ assert_eq!(app.input, "");
+ assert_eq!(app.input_cursor, 0);
+ }
+
+ #[test]
+ fn esc_exiting_nav_mode_does_not_clear_the_input() {
+ // ADR-0049 / ADR-0046 DC3: while a sidebar panel is focused
+ // (Ctrl-O), Esc exits navigation mode — the nav handler
+ // consumes it upstream of the input-field keymap, so the
+ // partly-typed command is preserved, NOT cleared.
+ let mut app = App::new();
+ type_str(&mut app, "create table T");
+ app.update(key_mod(KeyCode::Char('o'), KeyModifiers::CONTROL));
+ assert_eq!(app.nav_focus, NavFocus::SidebarTables);
+ // The draft survives entering nav mode.
+ assert_eq!(app.input, "create table T");
+ app.update(key(KeyCode::Esc));
+ // Esc returned focus to the input WITHOUT clearing it.
+ assert_eq!(app.nav_focus, NavFocus::Input);
+ assert_eq!(app.input, "create table T");
+ assert_eq!(app.input_cursor, "create table T".len());
+ }
+
#[test]
fn relationships_refreshed_event_updates_the_field() {
// ADR-0046 DB2: the runtime posts RelationshipsRefreshed; the
@@ -5999,7 +6679,7 @@ mod tests {
assert!(
matches!(
actions.as_slice(),
- [Action::JournalFailure { source }] if source == "florp glorp"
+ [Action::JournalFailure { source, .. }] if source == "florp glorp"
),
"expected JournalFailure for the typo'd line; got {actions:?}",
);
@@ -6022,11 +6702,12 @@ mod tests {
},
facts: crate::friendly::FailureContext::default(),
source: "drop table Ghost".to_string(),
+ advanced: false,
});
assert!(
matches!(
actions.as_slice(),
- [Action::JournalFailure { source }] if source == "drop table Ghost"
+ [Action::JournalFailure { source, .. }] if source == "drop table Ghost"
),
"expected JournalFailure carrying the source; got {actions:?}",
);
@@ -6155,6 +6836,80 @@ mod tests {
assert_eq!(app.input, "drop table AX");
}
+ // ---- ADR-0052 (issue #30): mode-aware history recall ----
+
+ #[test]
+ fn one_shot_advanced_command_recalls_with_colon_in_simple_mode() {
+ // The bug: a `:`-one-shot advanced command must recall WITH the
+ // `:` so it re-runs in simple mode (in-session and, via the
+ // `:`-prefixed ring form, across sessions too).
+ let mut app = App::new();
+ type_str(&mut app, ": select 1");
+ submit(&mut app);
+ app.update(key(KeyCode::Up));
+ assert_eq!(app.input, ": select 1");
+ }
+
+ #[test]
+ fn persistent_advanced_command_recalls_with_colon_back_in_simple_mode() {
+ // The feature: a command typed in *persistent* advanced mode
+ // recalls into simple mode with a `:` so it stays runnable.
+ let mut app = App::new();
+ app.mode = Mode::Advanced;
+ type_str(&mut app, "select 1");
+ submit(&mut app);
+ // Switch back to simple and recall.
+ app.mode = Mode::Simple;
+ app.update(key(KeyCode::Up));
+ assert_eq!(app.input, ": select 1");
+ }
+
+ #[test]
+ fn advanced_command_recalls_bare_in_advanced_mode() {
+ // In advanced mode the stored `:`-prefix is stripped so it runs
+ // as bare SQL.
+ let mut app = App::new();
+ app.mode = Mode::Advanced;
+ type_str(&mut app, "select 1");
+ submit(&mut app);
+ app.update(key(KeyCode::Up));
+ assert_eq!(app.input, "select 1");
+ }
+
+ #[test]
+ fn simple_command_recalls_bare_in_either_mode() {
+ let mut app = App::new();
+ type_str(&mut app, "drop table T");
+ submit(&mut app);
+ app.update(key(KeyCode::Up));
+ assert_eq!(app.input, "drop table T");
+ app.mode = Mode::Advanced;
+ app.update(key(KeyCode::Down)); // back to draft
+ app.update(key(KeyCode::Up));
+ assert_eq!(app.input, "drop table T");
+ }
+
+ #[test]
+ fn app_command_recalls_bare_even_when_typed_with_colon() {
+ // An app command runs in any mode, so it must NOT gain a `:` on
+ // recall even when entered via the one-shot escape.
+ let mut app = App::new();
+ type_str(&mut app, ": mode advanced");
+ submit(&mut app);
+ app.update(key(KeyCode::Up));
+ assert_eq!(app.input, "mode advanced");
+ }
+
+ #[test]
+ fn a_bare_colon_is_not_recorded_in_history() {
+ let mut app = App::new();
+ type_str(&mut app, ":");
+ submit(&mut app);
+ // Nothing recallable.
+ app.update(key(KeyCode::Up));
+ assert_eq!(app.input, "");
+ }
+
#[test]
fn add_column_with_text_type_emits_execute_action() {
let mut app = App::new();
diff --git a/src/db.rs b/src/db.rs
index a057ebe..80ebb64 100644
--- a/src/db.rs
+++ b/src/db.rs
@@ -608,7 +608,6 @@ enum Request {
},
DescribeTable {
name: String,
- source: Option,
reply: oneshot::Sender>,
},
AddRelationship {
@@ -748,7 +747,6 @@ enum Request {
table: String,
filter: Option,
limit: Option,
- source: Option,
reply: oneshot::Sender>,
},
/// Run a SQL `SELECT` typed by the user in advanced mode
@@ -757,11 +755,11 @@ enum Request {
/// prepares and runs the statement and returns the rows as
/// a [`DataResult`] (with no playground type information per
/// ADR-0030 §6 — computed columns render with neutral
- /// alignment). `source` is the literal submitted line,
- /// appended to `history.log` for replay (ADR-0030 §11).
+ /// alignment). The literal submitted line is journalled to
+ /// `history.log` at the dispatch layer for replay (ADR-0030 §11,
+ /// ADR-0052).
RunSelect {
sql: String,
- source: Option,
reply: oneshot::Sender>,
},
/// Run a validated SQL `INSERT` typed in advanced mode
@@ -1418,18 +1416,9 @@ impl Database {
recv.await.map_err(|_| DbError::WorkerGone)?
}
- pub async fn describe_table(
- &self,
- name: String,
- source: Option,
- ) -> Result {
+ pub async fn describe_table(&self, name: String) -> Result {
let (reply, recv) = oneshot::channel();
- self.send(Request::DescribeTable {
- name,
- source,
- reply,
- })
- .await?;
+ self.send(Request::DescribeTable { name, reply }).await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
@@ -1608,14 +1597,12 @@ impl Database {
table: String,
filter: Option,
limit: Option,
- source: Option,
) -> Result {
let (reply, recv) = oneshot::channel();
self.send(Request::QueryData {
table,
filter,
limit,
- source,
reply,
})
.await?;
@@ -1624,15 +1611,11 @@ impl Database {
/// Run a validated SQL `SELECT` and return the rows
/// (ADR-0030 §6, ADR-0031). `sql` is the grammar-validated
- /// statement text; `source` is the literal submitted line
- /// for `history.log`.
- pub async fn run_select(
- &self,
- sql: String,
- source: Option,
- ) -> Result {
+ /// statement text; the literal submitted line is journalled
+ /// at the dispatch layer (ADR-0052).
+ pub async fn run_select(&self, sql: String) -> Result {
let (reply, recv) = oneshot::channel();
- self.send(Request::RunSelect { sql, source, reply }).await?;
+ self.send(Request::RunSelect { sql, reply }).await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
@@ -2235,7 +2218,6 @@ fn handle_request(
snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_create_table(
conn,
persistence,
- source.as_deref(),
&name,
&columns,
&primary_key,
@@ -2262,19 +2244,16 @@ fn handle_request(
// (`show table`), it belongs in the complete journal
// (ADR-0034). ADR-0035 §4.
if if_not_exists && user_table_exists(conn, &name).unwrap_or(false) {
- let result = do_describe_table(conn, &name).and_then(|desc| {
- if let (Some(p), Some(text)) = (persistence, source.as_deref()) {
- p.append_history(text).map_err(DbError::from_persistence)?;
- }
- Ok(CreateOutcome::Skipped(desc))
- });
+ // ADR-0052: journaling moved to the dispatch layer; this
+ // no-op skip is an `Ok` outcome there and is journalled by
+ // the spawn like any other.
+ let result = do_describe_table(conn, &name).map(CreateOutcome::Skipped);
let _ = reply.send(result);
} else {
snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
do_create_table(
conn,
persistence,
- source.as_deref(),
&name,
&columns,
&primary_key,
@@ -2292,7 +2271,7 @@ fn handle_request(
reply,
} => {
snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
- do_drop_table(conn, persistence, source.as_deref(), &name)
+ do_drop_table(conn, persistence, &name)
});
}
Request::SqlDropTable {
@@ -2306,16 +2285,12 @@ fn handle_request(
// line is still journalled — like the `CREATE TABLE IF NOT
// EXISTS` skip and other no-ops (ADR-0034). ADR-0035 §4.
if if_exists && !user_table_exists(conn, &name).unwrap_or(false) {
- let result = (|| {
- if let (Some(p), Some(text)) = (persistence, source.as_deref()) {
- p.append_history(text).map_err(DbError::from_persistence)?;
- }
- Ok(DropOutcome::Skipped)
- })();
+ // ADR-0052: journaling moved to the dispatch layer.
+ let result: Result = Ok(DropOutcome::Skipped);
let _ = reply.send(result);
} else {
snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
- do_drop_table(conn, persistence, source.as_deref(), &name)
+ do_drop_table(conn, persistence, &name)
.map(|()| DropOutcome::Dropped)
});
}
@@ -2329,7 +2304,6 @@ fn handle_request(
snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_add_column(
conn,
persistence,
- source.as_deref(),
&table,
&column,
));
@@ -2344,7 +2318,6 @@ fn handle_request(
snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_drop_column(
conn,
persistence,
- source.as_deref(),
&table,
&column,
cascade,
@@ -2360,7 +2333,6 @@ fn handle_request(
snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_rename_column(
conn,
persistence,
- source.as_deref(),
&table,
&old,
&new,
@@ -2375,7 +2347,6 @@ fn handle_request(
snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_rename_table(
conn,
persistence,
- source.as_deref(),
&table,
&new,
));
@@ -2391,7 +2362,6 @@ fn handle_request(
snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_change_column_type(
conn,
persistence,
- source.as_deref(),
&table,
&column,
ty,
@@ -2407,17 +2377,8 @@ fn handle_request(
Request::ShowRelationship { name, reply } => {
let _ = reply.send(do_show_relationship(conn, &name));
}
- Request::DescribeTable {
- name,
- source,
- reply,
- } => {
- let _ = reply.send(do_describe_table_request(
- conn,
- persistence,
- source.as_deref(),
- &name,
- ));
+ Request::DescribeTable { name, reply } => {
+ let _ = reply.send(do_describe_table(conn, &name));
}
Request::AddRelationship {
name,
@@ -2434,7 +2395,6 @@ fn handle_request(
snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_add_relationship(
conn,
persistence,
- source.as_deref(),
name.as_deref(),
&parent_table,
&parent_columns,
@@ -2456,7 +2416,6 @@ fn handle_request(
do_create_m2n_relationship(
conn,
persistence,
- source.as_deref(),
&t1,
&t2,
name.as_deref(),
@@ -2471,7 +2430,6 @@ fn handle_request(
snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_drop_relationship(
conn,
persistence,
- source.as_deref(),
&selector,
));
}
@@ -2485,7 +2443,6 @@ fn handle_request(
snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_add_index(
conn,
persistence,
- source.as_deref(),
name.as_deref(),
&table,
&columns,
@@ -2503,7 +2460,6 @@ fn handle_request(
snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_drop_index(
conn,
persistence,
- source.as_deref(),
&selector,
));
}
@@ -2519,19 +2475,15 @@ fn handle_request(
// ADR-0035 §4). Existence uses the same user-index lookup as
// `do_drop_index` (`sql IS NOT NULL`).
if if_exists && !index_exists(conn, &name, true).unwrap_or(false) {
- let result = (|| {
- if let (Some(p), Some(text)) = (persistence, source.as_deref()) {
- p.append_history(text).map_err(DbError::from_persistence)?;
- }
- Ok(DropIndexOutcome::Skipped)
- })();
+ // ADR-0052: journaling moved to the dispatch layer.
+ let result: Result =
+ Ok(DropIndexOutcome::Skipped);
let _ = reply.send(result);
} else {
snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
do_drop_index(
conn,
persistence,
- source.as_deref(),
&IndexSelector::Named { name: name.clone() },
)
.map(DropIndexOutcome::Dropped)
@@ -2555,19 +2507,15 @@ fn handle_request(
// hits `do_add_index`'s redundant-set refusal (ADR-0025).
let resolved = resolve_index_name(name.as_deref(), &table, &columns);
if if_not_exists && index_exists(conn, &resolved, false).unwrap_or(false) {
- let result = (|| {
- if let (Some(p), Some(text)) = (persistence, source.as_deref()) {
- p.append_history(text).map_err(DbError::from_persistence)?;
- }
- Ok(CreateIndexOutcome::Skipped(resolved.clone()))
- })();
+ // ADR-0052: journaling moved to the dispatch layer.
+ let result: Result =
+ Ok(CreateIndexOutcome::Skipped(resolved));
let _ = reply.send(result);
} else {
snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
do_add_index(
conn,
persistence,
- source.as_deref(),
name.as_deref(),
&table,
&columns,
@@ -2587,7 +2535,6 @@ fn handle_request(
snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_add_constraint(
conn,
persistence,
- source.as_deref(),
&table,
&column,
&constraint,
@@ -2603,7 +2550,6 @@ fn handle_request(
snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_drop_constraint(
conn,
persistence,
- source.as_deref(),
&table,
&column,
kind,
@@ -2620,7 +2566,6 @@ fn handle_request(
do_set_column_default(
conn,
persistence,
- source.as_deref(),
&table,
&column,
&default_sql,
@@ -2638,7 +2583,6 @@ fn handle_request(
do_alter_add_table_check(
conn,
persistence,
- source.as_deref(),
&table,
name.as_deref(),
&expr_sql,
@@ -2652,7 +2596,7 @@ fn handle_request(
reply,
} => {
snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
- do_alter_add_unique(conn, persistence, source.as_deref(), &table, &columns)
+ do_alter_add_unique(conn, persistence, &table, &columns)
});
}
Request::AlterDropConstraint {
@@ -2662,7 +2606,7 @@ fn handle_request(
reply,
} => {
snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
- do_drop_constraint_by_name(conn, persistence, source.as_deref(), &table, &name)
+ do_drop_constraint_by_name(conn, persistence, &table, &name)
});
}
Request::AlterAddForeignKey {
@@ -2676,7 +2620,6 @@ fn handle_request(
do_alter_add_foreign_key(
conn,
persistence,
- source.as_deref(),
&child_table,
name.as_deref(),
&fk,
@@ -2693,7 +2636,6 @@ fn handle_request(
snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_insert(
conn,
persistence,
- source.as_deref(),
&table,
columns.as_deref(),
&values,
@@ -2713,7 +2655,6 @@ fn handle_request(
snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_seed(
conn,
persistence,
- source.as_deref(),
&table,
target_column.as_deref(),
count,
@@ -2731,7 +2672,6 @@ fn handle_request(
snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_update(
conn,
persistence,
- source.as_deref(),
&table,
&assignments,
&filter,
@@ -2746,7 +2686,6 @@ fn handle_request(
snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_delete(
conn,
persistence,
- source.as_deref(),
&table,
&filter,
));
@@ -2755,25 +2694,12 @@ fn handle_request(
table,
filter,
limit,
- source,
reply,
} => {
- let _ = reply.send(do_query_data_request(
- conn,
- persistence,
- source.as_deref(),
- &table,
- filter.as_ref(),
- limit,
- ));
+ let _ = reply.send(do_query_data(conn, &table, filter.as_ref(), limit));
}
- Request::RunSelect { sql, source, reply } => {
- let _ = reply.send(do_run_select_request(
- conn,
- persistence,
- source.as_deref(),
- &sql,
- ));
+ Request::RunSelect { sql, reply } => {
+ let _ = reply.send(do_run_select(conn, &sql));
}
Request::RunSqlInsert {
sql,
@@ -2788,7 +2714,6 @@ fn handle_request(
snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_sql_insert(
conn,
persistence,
- source.as_deref(),
&sql,
&target_table,
&listed_columns,
@@ -2808,7 +2733,6 @@ fn handle_request(
snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_sql_update(
conn,
persistence,
- source.as_deref(),
&sql,
&target_table,
returning,
@@ -2825,7 +2749,6 @@ fn handle_request(
snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_sql_delete(
conn,
persistence,
- source.as_deref(),
&sql,
&target_table,
returning,
@@ -2836,12 +2759,9 @@ fn handle_request(
source,
reply,
} => {
- snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_rebuild_from_text(
- conn,
- persistence,
- source.as_deref(),
- &project_path,
- ));
+ snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
+ do_rebuild_from_text(conn, &project_path)
+ });
}
Request::ExplainPlan { query, reply } => {
let _ = reply.send(do_explain_plan(conn, &query));
@@ -3065,10 +2985,16 @@ struct Changes {
/// Read-only requests (no schema change, no row writes, no
/// drops) still use this to append `history.log` if `source`
/// is set; they pass an empty `Changes`.
+// Persist the **state** sources (project.yaml + data/*.csv) for a
+// committed mutation, inside the worker transaction (ADR-0015 §6
+// commit-db-last). `history.log` is NOT written here — ADR-0052 moved
+// journaling to the dispatch layer (runtime), so the command's mode is
+// available without plumbing it through the worker, and a journal-write
+// failure no longer rolls back a committed command (it is best-effort,
+// like the failure path).
fn finalize_persistence(
conn: &Connection,
persistence: Option<&Persistence>,
- source: Option<&str>,
changes: &Changes,
) -> Result<(), DbError> {
let Some(p) = persistence else {
@@ -3093,10 +3019,6 @@ fn finalize_persistence(
p.delete_table_data(table)
.map_err(DbError::from_persistence)?;
}
- if let Some(text) = source {
- p.append_history(text)
- .map_err(DbError::from_persistence)?;
- }
Ok(())
}
@@ -3529,7 +3451,6 @@ pub enum CreateIndexOutcome {
fn do_create_table(
conn: &Connection,
persistence: Option<&Persistence>,
- source: Option<&str>,
name: &str,
columns: &[ColumnSpec],
primary_key: &[String],
@@ -3713,7 +3634,7 @@ fn do_create_table(
rewritten_tables: vec![name.to_string()],
..Changes::default()
};
- finalize_persistence(conn, persistence, source, &changes)?;
+ finalize_persistence(conn, persistence, &changes)?;
tx.commit().map_err(DbError::from_rusqlite)?;
Ok(description)
}
@@ -3721,7 +3642,6 @@ fn do_create_table(
fn do_drop_table(
conn: &Connection,
persistence: Option<&Persistence>,
- source: Option<&str>,
name: &str,
) -> Result<(), DbError> {
debug!(table = %name, "drop_table");
@@ -3773,7 +3693,7 @@ fn do_drop_table(
rewritten_tables: Vec::new(),
deleted_tables: vec![name.to_string()],
};
- finalize_persistence(conn, persistence, source, &changes)?;
+ finalize_persistence(conn, persistence, &changes)?;
tx.commit().map_err(DbError::from_rusqlite)?;
Ok(())
}
@@ -3800,7 +3720,6 @@ fn do_drop_table(
fn do_add_column(
conn: &Connection,
persistence: Option<&Persistence>,
- source: Option<&str>,
table: &str,
column: &ColumnSpec,
) -> Result {
@@ -3831,7 +3750,7 @@ fn do_add_column(
column.name,
)));
}
- return do_add_auto_generated_column(conn, persistence, source, table, column);
+ return do_add_auto_generated_column(conn, persistence, table, column);
}
// SQLite's `ALTER TABLE ADD COLUMN` cannot express `UNIQUE`
// or `CHECK`, and a `NOT NULL` column added that way must
@@ -3844,9 +3763,9 @@ fn do_add_column(
|| column.check_sql.is_some()
|| (column.not_null && column.default.is_none() && column.default_sql.is_none())
{
- do_add_constrained_column_via_rebuild(conn, persistence, source, table, column)
+ do_add_constrained_column_via_rebuild(conn, persistence, table, column)
} else {
- do_add_plain_column(conn, persistence, source, table, column)
+ do_add_plain_column(conn, persistence, table, column)
}
}
@@ -3854,7 +3773,6 @@ fn do_add_column(
fn do_add_plain_column(
conn: &Connection,
persistence: Option<&Persistence>,
- source: Option<&str>,
table: &str,
spec: &ColumnSpec,
) -> Result {
@@ -3891,7 +3809,7 @@ fn do_add_plain_column(
rewritten_tables: vec![table.to_string()],
..Changes::default()
};
- finalize_persistence(conn, persistence, source, &changes)?;
+ finalize_persistence(conn, persistence, &changes)?;
tx.commit().map_err(DbError::from_rusqlite)?;
Ok(AddColumnResult {
description,
@@ -3907,7 +3825,6 @@ fn do_add_plain_column(
fn do_add_auto_generated_column(
conn: &Connection,
persistence: Option<&Persistence>,
- source: Option<&str>,
table: &str,
spec: &ColumnSpec,
) -> Result {
@@ -4014,7 +3931,7 @@ fn do_add_auto_generated_column(
rewritten_tables: vec![table.to_string()],
..Changes::default()
};
- finalize_persistence(tx, persistence, source, &changes)?;
+ finalize_persistence(tx, persistence, &changes)?;
Ok(())
};
@@ -4039,7 +3956,6 @@ fn do_add_auto_generated_column(
fn do_add_constrained_column_via_rebuild(
conn: &Connection,
persistence: Option<&Persistence>,
- source: Option<&str>,
table: &str,
spec: &ColumnSpec,
) -> Result {
@@ -4114,7 +4030,7 @@ fn do_add_constrained_column_via_rebuild(
rewritten_tables: vec![table.to_string()],
..Changes::default()
};
- finalize_persistence(tx, persistence, source, &changes)?;
+ finalize_persistence(tx, persistence, &changes)?;
Ok(())
};
@@ -4140,7 +4056,6 @@ fn do_add_constrained_column_via_rebuild(
fn do_add_constraint(
conn: &Connection,
persistence: Option<&Persistence>,
- source: Option<&str>,
table: &str,
column: &str,
constraint: &Constraint,
@@ -4267,7 +4182,7 @@ fn do_add_constraint(
rewritten_tables: vec![table.to_string()],
..Changes::default()
};
- finalize_persistence(tx, persistence, source, &changes)?;
+ finalize_persistence(tx, persistence, &changes)?;
Ok(())
};
@@ -4283,7 +4198,6 @@ fn do_add_constraint(
fn do_drop_constraint(
conn: &Connection,
persistence: Option<&Persistence>,
- source: Option<&str>,
table: &str,
column: &str,
kind: ConstraintKind,
@@ -4367,7 +4281,7 @@ fn do_drop_constraint(
rewritten_tables: vec![table.to_string()],
..Changes::default()
};
- finalize_persistence(tx, persistence, source, &changes)?;
+ finalize_persistence(tx, persistence, &changes)?;
Ok(())
};
@@ -4386,7 +4300,6 @@ fn do_drop_constraint(
fn do_set_column_default(
conn: &Connection,
persistence: Option<&Persistence>,
- source: Option<&str>,
table: &str,
column: &str,
default_sql: &str,
@@ -4434,7 +4347,7 @@ fn do_set_column_default(
rewritten_tables: vec![table.to_string()],
..Changes::default()
};
- finalize_persistence(tx, persistence, source, &changes)?;
+ finalize_persistence(tx, persistence, &changes)?;
Ok(())
};
@@ -4776,7 +4689,6 @@ fn format_auto_fill_add_note(ty: Type, row_count: usize) -> String {
fn do_drop_column(
conn: &Connection,
persistence: Option<&Persistence>,
- source: Option<&str>,
table: &str,
column: &str,
cascade: bool,
@@ -4918,7 +4830,7 @@ fn do_drop_column(
rewritten_tables: vec![table.to_string()],
..Changes::default()
};
- finalize_persistence(conn, persistence, source, &changes)?;
+ finalize_persistence(conn, persistence, &changes)?;
tx.commit().map_err(DbError::from_rusqlite)?;
Ok(DropColumnResult {
description,
@@ -4936,7 +4848,6 @@ fn do_drop_column(
fn do_rename_column(
conn: &Connection,
persistence: Option<&Persistence>,
- source: Option<&str>,
table: &str,
old: &str,
new: &str,
@@ -5028,7 +4939,7 @@ fn do_rename_column(
rewritten_tables: vec![table.to_string()],
..Changes::default()
};
- finalize_persistence(conn, persistence, source, &changes)?;
+ finalize_persistence(conn, persistence, &changes)?;
tx.commit().map_err(DbError::from_rusqlite)?;
Ok(description)
}
@@ -5060,7 +4971,6 @@ fn do_rename_column(
fn do_rename_table(
conn: &Connection,
persistence: Option<&Persistence>,
- source: Option<&str>,
old: &str,
new: &str,
) -> Result {
@@ -5206,7 +5116,7 @@ fn do_rename_table(
rewritten_tables: vec![new.to_string()],
deleted_tables: vec![old.to_string()],
};
- finalize_persistence(conn, persistence, source, &changes)?;
+ finalize_persistence(conn, persistence, &changes)?;
tx.commit().map_err(DbError::from_rusqlite)?;
Ok(description)
}
@@ -5247,7 +5157,6 @@ fn do_rename_table(
fn do_change_column_type(
conn: &Connection,
persistence: Option<&Persistence>,
- source: Option<&str>,
table: &str,
column: &str,
ty: Type,
@@ -5365,7 +5274,7 @@ fn do_change_column_type(
rewritten_tables: vec![table.to_string()],
..Changes::default()
};
- finalize_persistence(tx, persistence, source, &changes)?;
+ finalize_persistence(tx, persistence, &changes)?;
Ok(())
};
@@ -7450,7 +7359,6 @@ fn resolve_create_table_fks(
fn do_create_m2n_relationship(
conn: &Connection,
persistence: Option<&Persistence>,
- source: Option<&str>,
t1: &str,
t2: &str,
name: Option<&str>,
@@ -7524,7 +7432,6 @@ fn do_create_m2n_relationship(
do_create_table(
conn,
persistence,
- source,
&junction,
&columns,
&primary_key,
@@ -7538,7 +7445,6 @@ fn do_create_m2n_relationship(
fn do_add_relationship(
conn: &Connection,
persistence: Option<&Persistence>,
- source: Option<&str>,
name: Option<&str>,
parent_table: &str,
parent_columns: &[String],
@@ -7693,7 +7599,7 @@ fn do_add_relationship(
rewritten_tables: vec![child_table.to_string()],
..Changes::default()
};
- finalize_persistence(tx, persistence, source, &changes)?;
+ finalize_persistence(tx, persistence, &changes)?;
Ok(())
})?;
@@ -7707,7 +7613,6 @@ fn do_add_relationship(
fn do_drop_relationship(
conn: &Connection,
persistence: Option<&Persistence>,
- source: Option<&str>,
selector: &RelationshipSelector,
) -> Result