# 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.