# ADR-0038: The DSL → SQL teaching echo

## Status

Proposed (design agreed with the user 2026-05-27; pending `/runda` +
implementation). **Realises ADR-0030 §10** ("The DSL → SQL teaching
bridge") — the Phase-5 echo that **ADR-0035 §12 forward-referenced** as
"a separate ADR." Builds on **ADR-0037** (the execution-time mode
side-channel that gates it) and **ADR-0035 Amendment 2** (the standard-
first dialect + `ALTER COLUMN` gap-fill that makes its DDL echoes
runnable).

## Context

ADR-0030 §10 specified a teaching bridge: when a **DSL-form** command
runs **in advanced mode**, its output includes the equivalent SQL, so a
learner who knows the simple-mode form reads off how to spell it in SQL.
The §10 contract: fires only for DSL-entered commands in advanced mode
(a command already typed as SQL is not echoed; simple mode stays
uncluttered); renders as a de-emphasised `OutputLine` beneath the `[ok]`
summary (styled-runs, ADR-0028); app-level commands have no SQL form and
are not echoed; the reverse SQL → DSL echo is out of scope (§13 OOS-5).

### The firing reality — this is a DDL + `show data` feature

A consequence of ADR-0033 Amendment 3 sharpens the scope and must be
stated plainly, because it is easy to over-estimate coverage: **in
advanced mode the overlapping data commands route SQL-first.** A user who
types `insert into T values (…)` / `update T set …` / `delete from T
where …` in advanced mode produces `Command::SqlInsert` / `SqlUpdate` /
`SqlDelete` — they **already typed SQL**, so §10 explicitly does not echo
them. The echo is therefore meaningful only where the **DSL spelling
differs from the SQL spelling** — i.e. the DSL-*only* surface:

- **DDL DSL spellings** with no winning SQL competitor — `create table …
  with pk`, `add`/`drop`/`rename`/`change column`, `add`/`drop
  constraint`, `add`/`drop index`, `add`/`drop relationship`.
- **`show data`** (DSL-only; SQL uses `SELECT`).
- The **`--all-rows` fall-throughs** — `delete … --all-rows` falls back
  to the DSL `Delete` (the SQL `DELETE` has no slot for the flag), and
  `update … --all-rows` likewise falls back to the DSL `Update` per
  **ADR-0033 Amendment 4** (which reclassifies Amendment 3's non-fall-
  back of `update … --all-rows` as a bug and reverses it). Plain /
  `where`-filtered `insert`/`update`/`delete` stay SQL-first (`Sql*`).

Everything a learner types that is *already SQL* needs no echo by
construction. So the catalogue below is dominated by DDL.

### Dependencies

- **ADR-0037** delivers the `SubmissionMode` to the worker, so a command
  knows at execution time whether it ran under `Advanced` /
  `AdvancedOneShot` (echo) or `Simple` (silent).
- **ADR-0035 Amendment 2** adds the `ALTER COLUMN SET/DROP NOT NULL`,
  `SET/DROP DEFAULT`, and the ISO `SET DATA TYPE` canonical form, so the
  constraint-modification echoes are runnable advanced-mode SQL, and
  fixes the dialect stance the echoes emit in.

## Decision

### 1. The copy-paste contract — every echo is runnable advanced-mode SQL

The defining invariant, stronger than "looks like SQL": **each echoed
line is exactly what the user could paste into advanced mode and run —
to the same effect, except where a Category-3 *caveat* line (§6) flags a
divergence the SQL surface cannot express** (today the sole such case is
`change column … --dont-convert`). This is testable as a **round-trip**:
parse the echo through the advanced-mode walker and assert it yields a
command with the same effect as the one that produced it (a caveated
line still parses and runs — it just runs differently, which the caveat
states). A planned one-shot "copy the
echo" UX affordance depends on this contract, so it is a hard
requirement, not a nicety. Multi-statement echoes (§6 category 2) hold
the contract per line.

### 2. Type vocabulary — the playground's own keywords

The echo emits the playground's `Type::keyword()` spelling (`serial`,
`shortid`, `decimal`, `bool`, `date`, `datetime`, …), **not** engine
storage types. This is sound because the advanced-mode type slot
(`Type::from_sql_name`) accepts those ten keywords, so the echo round-
trips (§1), and because the curated vocabulary is what the learner knows
from the DSL and what carries the teaching information (`serial`'s auto-
increment intent, `shortid`'s identity flavour) that `INTEGER`/`TEXT`
would erase. The **statement shape** follows the standard-first dialect
(ADR-0035 Amendment 2); the **type slots** carry playground keywords.

### 3. Firing rule

The echo fires **iff** the executed command is a **DSL-form** command —
*not* a `Sql*` variant and *not* a `Command::App(_)` — **and** its
`SubmissionMode` (ADR-0037) is `Advanced` or `AdvancedOneShot` **and** the
command is an interactive submission (**not** a `replay`ed line). Silent
in `Simple`; silent for SQL-entered commands (they are `Sql*`); silent
for app commands (no SQL form, §10); silent during `replay` (per-line
echoes would bury the replay summary — ADR-0037 §3).

### 4. Where it is built and rendered

The **worker builds** the echo (ADR-0037 §3) — it alone holds the facts
several echoes need: auto-resolved index / relationship names, generated
`shortid` values, and lossy-conversion counts. It returns the echo
payload on the result event. The **App renders** it as one or more
**de-emphasised** `OutputLine`s beneath the `[ok]` summary, using the
ADR-0028 styled-runs mechanism (a dimmed `Executing SQL:` prefix; the SQL
itself in a code-ish run). One statement per line (§6 category 2).

### 5. `Value → SQL-literal` rendering

DML echoes substitute the **actual literal values** (not `?`
placeholders — decision B1), so the line is runnable. A per-type renderer
produces SQL literals that **round-trip** (§1):

| Type | Literal form |
|---|---|
| `text` / `shortid` | single-quoted, `'` doubled (`'O''Hara'`) |
| `int` / `serial` | bare integer |
| `real` / `decimal` | bare numeric (decimal as authored) |
| `bool` | `true` / `false` |
| `date` / `datetime` | quoted ISO-8601 (`'2026-05-27'`) |
| `NULL` | `NULL` |

**`blob` has no literal:** neither the DSL nor advanced SQL has a blob-
literal syntax (the playground deliberately does not provide one), so a
DSL command **cannot carry a blob value** for the echo to render — this
is moot, not a gap.

**Auto-generated columns are omitted** from an `INSERT` echo: `do_insert`
filters out PK `serial` (rowid alias), non-PK `serial` (MAX+1), and
`shortid` columns the user did not list, and advanced-mode SQL auto-fills
the same omissions (`requirements.md` X4). So the echo omits them too —
matching both the executed statement and the advanced-mode form a learner
would type.

### 6. The three-category framework

Everything that happens "beyond the literal SQL line" sorts into exactly
three categories; naming them makes the rule testable:

- **Category 1 — engine-implementation-hiding. Never surfaced.** The
  table rebuild that backs `ALTER COLUMN …` / `ADD CONSTRAINT` /
  `RENAME TO` on this engine; the rowid alias behind a PK `serial`; the
  `MAX(col)+1` behind a non-PK `serial`. These are how *this* engine
  honours a standard operation — not the lesson (ADR-0030 §7,
  ADR-0035 §9). The headline echo shows the clean statement; the means
  stays invisible. (Non-PK `serial` auto-fill is **here, not category 3**:
  auto-increment is an ordinary database feature; only the engine's
  *means* of faking it on a non-PK column is hidden.)
- **Category 2 — decomposable into advanced-mode SQL. Shown as the
  runnable multi-line sequence.** One statement per line; the lines
  *are* the explanation, no prose. See `drop column --cascade` and
  `add relationship --create-fk` below.
- **Category 3 — a playground type-system behaviour the SQL line cannot
  express (no function, no clause exists to write it). Surfaced as a
  de-emphasised prose line.** The note is one of two kinds:
  *illuminating* — the headline echo *is* equivalent and the note merely
  reveals a value-add the SQL does not show; or *caveat* — the headline
  is the **nearest** SQL but **not** equivalent because a playground
  flag's semantics have no SQL form (these caveats are the §1 contract's
  only exceptions). Members:
  - **`shortid` generation** *(illuminating)* — no `shortid()` function
    exists, so the generation cannot be written in SQL: "generated unique
    shortid(s) for `<col>`".
  - **type-conversion transform** *(illuminating)* — `change column type`
    when cells are actually transformed; the playground auto-converts
    where standard SQL would need a cast clause it deliberately omits
    (the Postgres `USING`, ADR-0035 §12): "converted `<col>` from
    `<old>` to `<new>` [N values with loss]".
  - **`change column … --dont-convert`** *(caveat)* — the headline
    `ALTER TABLE T ALTER COLUMN c SET DATA TYPE <ty>` converts, but
    `--dont-convert` left the stored values as-is, so the line is not
    equivalent: "`--dont-convert` kept the stored values as-is; standard
    SQL always converts, so running the line above would transform them
    instead."

  All are built from the worker's existing `client_side.*` result notes
  (ADR-0017 §6 / ADR-0018 §9), not recomputed.

### 7. The catalogue

DSL-form commands that surface in advanced mode, with their echo. The
SQL uses the standard-first dialect (ADR-0035 Amendment 2) and playground
type keywords (§2). `<name>` denotes a worker-resolved name (auto-
generated or positional → resolved at execution, "B1").

**Bucket A — single runnable statement.**

| DSL command | Echoed SQL | Cat-3 expansion |
|---|---|---|
| `create table T with pk` | `CREATE TABLE T (id serial PRIMARY KEY)` | — |
| `create table T with pk a(int),b(int)` | `CREATE TABLE T (a int, b int, PRIMARY KEY (a, b))` | — |
| `add column to T: c (<ty>) [not null] [unique] [default v] [check e]` | `ALTER TABLE T ADD COLUMN c <ty> [NOT NULL] [UNIQUE] [DEFAULT v] [CHECK (e)]` | shortid (if `<ty>` = shortid) |
| `drop column from T: c` *(no covering index)* | `ALTER TABLE T DROP COLUMN c` | — |
| `rename column in T: old to new` | `ALTER TABLE T RENAME COLUMN old TO new` | — |
| `change column in T: c (<ty>)` | `ALTER TABLE T ALTER COLUMN c SET DATA TYPE <ty>` | conversion *(illum.)* if transformed (incl. → shortid); `--dont-convert` → *caveat* (§6) |
| `add constraint not null to T.c` | `ALTER TABLE T ALTER COLUMN c SET NOT NULL` | — |
| `add constraint default <v> to T.c` | `ALTER TABLE T ALTER COLUMN c SET DEFAULT <v>` | — |
| `add constraint unique to T.c` | `ALTER TABLE T ADD UNIQUE (c)` | — |
| `add constraint check <e> to T.c` | `ALTER TABLE T ADD CHECK (e)` | — |
| `drop constraint not null from T.c` | `ALTER TABLE T ALTER COLUMN c DROP NOT NULL` | — |
| `drop constraint default from T.c` | `ALTER TABLE T ALTER COLUMN c DROP DEFAULT` | — |
| `add index [as N] on T (cols)` | `CREATE INDEX <name> ON T (cols)` | — |
| `show data T [where …] [limit n]` | `SELECT * FROM T [WHERE …] [ORDER BY <pk> LIMIT n]` | — |
| `delete from T --all-rows` | `DELETE FROM T` | — |
| `update T set … --all-rows` | `UPDATE T SET …` | — |

**Bucket B — resolved-name and multi-line (one statement per line).**

| DSL command | Echoed SQL | Notes |
|---|---|---|
| `drop index on T(cols)` *(positional)* | `DROP INDEX <name>` | name resolved at execution |
| `add 1:n relationship [as N] from P.pc to C.cc [on delete X] [on update Y]` | `ALTER TABLE C ADD CONSTRAINT <name> FOREIGN KEY (cc) REFERENCES P (pc) [ON DELETE X] [ON UPDATE Y]` | name resolved |
| `drop relationship (from P.pc to C.cc \| N)` | `ALTER TABLE C DROP CONSTRAINT <name>` | name resolved |
| `add 1:n relationship … --create-fk` *(child col created)* | `ALTER TABLE C ADD COLUMN cc <ty>` ⏎ `ALTER TABLE C ADD CONSTRAINT <name> FOREIGN KEY (cc) REFERENCES P (pc) …` | category 2: two runnable lines (one if the column already existed) |
| `drop column T.c --cascade` *(drops covering indexes)* | `DROP INDEX <ix1>` ⏎ … ⏎ `ALTER TABLE T DROP COLUMN c` | category 2: index names resolved; plain `DROP COLUMN` would refuse an indexed column |

**Bucket C — no echo.**

| DSL command | Reason |
|---|---|
| `show table T` | structure display; no SQL spelling in the surface |
| `explain …` | `EXPLAIN` of advanced SQL is OOS-2 (ADR-0030 §13) |
| `replay <path>` | app-lifecycle; no SQL form |
| `drop constraint unique/check from T.c` *(column-level)* | residual gap (ADR-0035 Amendment 2): anonymous column constraint, no portable name |
| `Command::App(_)` (all app commands) | §10 — no SQL form |
| `Sql*` variants, `select`/`with` | already SQL-entered — not echoed by construction |
| `insert …`, `update …` / `update … where …`, `delete …` / `delete … where …` | route SQL-first in advanced mode (`Sql*`) — already SQL, nothing to echo (the two `--all-rows` forms fall back to DSL and *do* echo — Bucket A) |

### 8. Coverage and phasing

The renderer is one mechanism, so the natural unit is "Bucket A + B at
once." A reasonable phasing if a smaller first slice is wanted:

- **Phase 1** — Bucket A single-statement DDL + `show data` (the bulk of
  the teaching value; no resolved-name or multi-line machinery).
- **Phase 2** — Bucket B (worker-resolved names; multi-line sequences).
- **Phase 3** — the category-3 prose expansion.

(Coverage/phasing is a sequencing choice for the user; the catalogue
itself is fixed.)

**Build-order dependencies (verified against the current grammar).** Two
groups of echoes emit SQL the advanced surface does **not yet parse**, so
their round-trip tests (§1) are gated on their prerequisites landing
first:

- The **constraint-modification** rows and the `change column` row —
  `ALTER COLUMN … SET DATA TYPE / SET NOT NULL / DROP NOT NULL /
  SET DEFAULT / DROP DEFAULT` — require **ADR-0035 Amendment 2**. Today
  `alter table T alter column c …` accepts only `type <ty>` (probed:
  `set data type` / `set not null` / `set default` all error with
  "expected `type`"). So Amendment 2 must build **before** these rows'
  round-trip tests. (`change column` could echo the `TYPE` synonym in
  the interim, but the canonical/emitted form is `SET DATA TYPE` per
  Amendment 2.)
- The **`update … --all-rows`** row requires **ADR-0033 Amendment 4**
  (today it is `SqlUpdate`, not the DSL `Update` the echo needs).

All other Bucket A/B rows round-trip against the surface as it stands
today (probe-confirmed: `create table T (id serial primary key)`,
compound PK, `add unique (c)`, `add check (…)`, `create index … on …`,
`select * from …` all parse in advanced mode).

### 9. Out of scope

- **The reverse SQL → DSL echo** (ADR-0030 §13 OOS-5).
- **App commands**, `show table`, `explain`, `replay` (Bucket C).
- **A `blob` literal renderer** (§5 — moot; no blob literal exists).
- **Closing the column-level UNIQUE/CHECK drop gap** (ADR-0035
  Amendment 2 residual) — those stay Bucket C until that gap is closed.
- **Surfacing category-1 engine internals** (the rebuild, rowid, MAX+1) —
  never (§6).

## Consequences

- A new `Command → SQL` renderer plus the `Value → SQL-literal` renderer,
  worker-side (§4), gated by `SubmissionMode` (ADR-0037). Each Bucket A/B
  row is a round-trip test (§1); each category-3 row asserts the prose
  fires from the worker note.
- The teaching surface is honest: a learner reads runnable, portable SQL
  in the playground's own type vocabulary; engine quirks stay hidden;
  playground-only conveniences are named, not mysterious.
- The echo's coverage is **DDL-centric by construction** (the firing
  reality, Context). This is correct, not a shortfall — overlapping DML
  is already SQL in advanced mode.
- Adding a new DSL-form DDL command later must add its catalogue row +
  round-trip test, or consciously place it in Bucket C.

## See also

- ADR-0030 §10 — the teaching bridge this realises; §13 OOS-5 (no reverse
  echo).
- ADR-0037 — the `SubmissionMode` side-channel that gates firing.
- ADR-0035 Amendment 2 — the standard-first dialect + `ALTER COLUMN`
  gap-fill the echoes emit and rely on.
- ADR-0033 Amendment 3 — the SQL-first dispatch that makes this a
  DDL + `show data` feature.
- ADR-0028 — the `OutputLine` styled-runs the echo renders through.
- ADR-0017 / ADR-0018 — the `client_side.*` notes feeding category-3.
