# 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<String>` — 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 `<ts>|<status>|<source>`
(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<String>`, 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<String>`; `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
  <name>`, 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).
