13 KiB
Session handoff — 2026-05-22 (31)
Thirty-first handover. This session shipped, in order:
- Finished the advanced-mode completion interlude left open by
handoff 30 — the F5 core
walk_seqfix, plus a follow-upwalk_repeatedfix and a candidate-ordering reversal that grew out of manual testing. - Advanced ADR-0033 Phase 3 through sub-phases 3c, 3d, 3e —
SQL
INSERT … SELECT,shortidauto-fill, and SQLUPDATE.
The next session should start sub-phase 3f (DELETE +
cascade summary) — the largest remaining DML sub-phase — which is
why we're handing off first. See §4 for a key 3f heads-up.
§1. State at handoff
Branch: main. Tests: 1524 passing, 0 failing, 1 ignored.
Clippy: clean (cargo clippy --all-targets -- -D warnings).
Commits this session (newest first). origin/main is at
6ff9144 (3c), so the last three are local-only:
53808ed grammar+db: 3e — SQL UPDATE grammar + execution (ADR-0033 §2) (local)
18d34d0 db: 3d fix — don't let shortid auto-fill mask INSERT arity mismatch (local)
78ad476 db+grammar: 3d — shortid auto-fill for SQL INSERT (ADR-0033 §6) (local)
6ff9144 grammar: 3c — INSERT … SELECT row source (ADR-0033 §4) (origin/main)
7f68a53 walker+completion: surface list trailing-optionals + identifiers-first ordering (ADR-0022 Amendment 2)
43c49f4 walker: F5 — drop preceding-clause keywords from committed-child Incomplete sets
(Unpushed commits are a normal working state; pushing is the user's step — do not prompt about it.)
§2. THE process lesson (read this first)
This session's clearest signal is about Devil's Advocate discipline, not code. Three times the lead produced a DA "verdict" that merely restated the exit gate — and three times the user had to prompt before a genuine adversarial pass happened. Each real pass then found something concrete:
- "What about more than one shortid in a row?" → an untested multi-shortid path (it worked, but had zero coverage).
- "Did you check with the DA?" → the arity-masking bug (3d
auto-fill silently dropped extra columns / could read out of
range). Real correctness bug; fixed in
18d34d0. - "Sounds like the DA had nothing to add." → the untested render
guard in 3e (
handle_dsl_update_success).
Takeaway for the next agent: do the adversarial work proactively and in writing, and make it produce a failing test or a concrete change — not a paragraph that clears everything. "The exit-gate tests pass" is not a DA review. Attack the code: untested branches, edge cases the happy-path tests skip, behaviour that diverges from the non-SQL path, things that only break when an input is malformed. A clean PASS with no findings on a non-trivial sub-phase should itself be suspicious.
§3. Phase 3 — what shipped (3c / 3d / 3e)
Controlling docs (read both):
docs/adr/0033-sql-dml-grammar.md— the decision (read Amendment 1 for the dispatch mechanism).docs/plans/20260520-adr-0033-phase-3.md— build order (3a–3k), per-sub-phase exit + DA gates.
3c — INSERT … SELECT (6ff9144)
Grammar-only: the INSERT row source became a
Choice(VALUES_CLAUSE, Subgrammar(&sql_select::SQL_SELECT_COMPOUND)).
SQL_SELECT_COMPOUND is itself a Choice that admits a leading
WITH, so the R4 (WITH-prefixed) row source parses for free. No
worker change — do_sql_insert already executes the validated SQL
and the engine handles insert-from-query.
3d — shortid auto-fill (78ad476) + arity fix (18d34d0)
Command::SqlInsert gained listed_columns and row_source,
extracted in build_sql_insert from the matched path (the row
source is found by the first values/select/with Word
token, which is path-based so a string literal like 'select'
can't be mistaken for the keyword). plan_shortid_autofill
(db.rs) implements the user-chosen Option B: when the column
list omits a shortid column, materialise the row source by
running it as a query, generate a distinct id per row via the
existing generate_shortid_batch (deduped against stored values),
and reconstruct a parameterised multi-row INSERT. Uniform for
VALUES and SELECT; handles multiple omitted shortids (one batch per
column). serial stays engine-filled via rowid. history.log
keeps the original line, never the rewrite (§11).
Arity guard (18d34d0): the auto-fill path read exactly
listed_columns.len() cells per row — a column/value arity
mismatch silently dropped extra columns or read out of range. Now:
if the materialised statement's column_count() != listed_count,
skip auto-fill and run verbatim so the engine reports the mismatch
(a friendly pre-flight is 3i).
Minor deviation from the plan: the plan's 3d step said "turn on
writes_user_listed_column". We did not flip that flag — the
worker only needs the column names, collected by role in
build_sql_insert. The flag drives completion-narrowing of VALUES
against the listed columns, which nothing needs yet. It remains
false; flipping it is a separate completion enhancement, not a
blocker.
3e — SQL UPDATE (53808ed)
New src/dsl/grammar/sql_update.rs: `SQL_UPDATE_SHAPE =
Two findings that matter for later sub-phases:
- Cross-cut diagnostics are NOT automatically free. The
schema-existence + predicate-warning passes (
mod.rs) build their scope from Tables idents whose role is"table_name"(the pre-pass atschema_existence_diagnostics). A bespoke role (update_target_table) left the SET/WHERE columns unchecked (diag_keysreturned[]). Fix: the UPDATE target uses the shared"table_name"role. 3f's DELETE target must do the same to get the predicate diagnostics on its WHERE for free. - Render guard. A column-less
UpdateResultwould render a misleading(no rows)band.handle_dsl_update_successnow skipsrender_data_tablewhenresult.data.columns.is_empty(). The DSL UPDATE always has columns, so it's unaffected. Covered by app-level tests both ways.
sql_select::WHERE_CLAUSE is now pub(crate) so the DML
statements reuse the exact predicate clause.
§4. Sub-phase 3f — the next job (DELETE + cascade)
Per the plan (docs/plans/20260520-adr-0033-phase-3.md, "Sub-phase
3f"): new src/dsl/grammar/sql_delete.rs (DELETE FROM table [WHERE] [';']), Command::SqlDelete / Request::RunSqlDelete /
do_sql_delete, cascade-summary pre-count (ADR-0033 §7), the
format_cascade_summary formatter shared with the DSL
do_delete, and multi-table persistence (target + every
cascade-affected child).
Heads-up — the WHERE-byte-extraction problem is tractable for
DELETE. In 3e I worried that a flat token path can't distinguish
a statement-level WHERE from a subquery's WHERE (a where token
can appear before or after the statement one). That ambiguity
comes from UPDATE's SET clause possibly holding a subquery-with-
WHERE before the statement WHERE. DELETE has no SET — nothing
before the statement WHERE can contain a subquery — so the
statement WHERE is simply the first where Word token after the
target table, and the predicate text is source[where_start..]
(trim trailing ;). The R2 invariant
(DELETE … WHERE x IN (SELECT … WHERE …)) is fine: the nested
subquery WHERE is inside the predicate, which is exactly what the
cascade pre-count wants to inject. So mirror the
build_sql_insert row_source / build_sql_update extraction: find
the first where token, capture the clause text into
Command::SqlDelete, and inject it into
SELECT <pk> FROM <target> <where_clause> for each child
pre-count. When there is no WHERE, the pre-count is unbounded
(all target rows).
Other 3f gotchas from the plan's DA gate: the cascade pre-count must run before the DELETE (the rows being counted are the ones about to vanish); cascade-affected child CSVs must all be re-persisted; and the SQL path's per-relationship summary must match the DSL path's on the same schema/data (shared formatter).
§5. Established patterns (reuse these in 3f–3i)
- Dev entry word per sub-phase. SQL DML is isolated behind
sqlinsert/sql_update(andsql_deletenext) entry words,CommandCategory::AdvancedinREGISTRY(src/dsl/grammar/mod.rs). Thebuild_sql_*ast-builder reconstructs the real keyword (insert/update/delete) + matched tail. 3j removes all dev words and makesinsert/update/deleteshared DSL/SQL entry words; 3j must also de-dup the completion entry-word lists once a word appears twice inREGISTRY(flagged in ADR-0033 Amendment 1). table_namerole for any target whose WHERE/SET columns need schema diagnostics (see §3e finding 1).- Static-vs-const in grammar files. A
Nodereferenced by value in astatic [...]array must beconst(so it inlines); aNodereferenced via&NODEcan bestatic. Getting this wrong gives "cannot move out of a shared reference" (hit twice in 3e). - Worker result + render. SQL DML reuses the DSL
CommandOutcome::{Insert,Update,Delete}and thehandle_dsl_*_successrenderers. Any newCommandvariant must be added to:command.rsverb()+target_table(), theruntime.rsdispatch,app.rsbuild_translate_context, and thetests/typing_surface/mod.rscommand_kind_labelmatch (all are non-exhaustive checks that will fail to compile until covered — a useful forcing function).
§6. Escalations settled this session (do not re-litigate)
- Identifiers-first candidate ordering (ADR-0022 Amendment 2): schema identifiers sort before keywords, globally — the user explicitly chose this over the handoff-14 keywords-first invariant, after seeing that long SQL keyword runs pushed column names off the single-row, window-scrolled candidate line. A two-line hint box is recorded as a deferred follow-up.
- 3d SELECT shortid strategy = Option B (materialise + dedup + reinsert), user-confirmed.
auto_column_overriddenWARNING stays INSERT-only (the plan default). 3e did not extend it to UPDATE; if 3f/3i wants to, the plan says escalate.
§7. Still deferred (tracked, not lost)
- RETURNING (3g) — precise DML row output; 3e/3f surface counts (+ cascade summary) only until then.
- UPSERT
ON CONFLICT(3h), diagnosticsinsert_arity_mismatch/auto_column_overridden/not_null_missing(3i), dispatch wiring / dev-word removal (3j), verification sweep (3k). - Old-project migration (from handoff 30 §4) — the pre-
check_expr3-column__rdbms_playground_columnsschema; the user deferred a migration story to "the end". Targeted fix when the migration framework (ADR-0015 Iter 6) lands. - Two-line hint box (ADR-0022 Amendment 2).
writes_user_listed_columnflag (see §3d) — VALUES-against- listed-columns completion narrowing.
§8. Process pins (unchanged, still binding)
- Confirm every commit. Propose the message and wait for the go-ahead. No auto-commit at sub-phase gates.
- Push is the user's step. Never push; never prompt about it.
- No AI attribution in commits (global rule).
- Test-first. Reproduce a bug with a FAILING test before fixing; for features, exit-gate tests before "done". This is also how the real DA findings surfaced this session — write the cross-cut/edge test and watch it fail.
- Core walker changes (
walk_seq/walk_choice/walk_repeated) need explicit user OK before coding. (F5 and the 3d-erawalk_repeatedchange both went through this.) - Escalate ambiguity; never classify work out of scope without user confirmation.
§9. How to take over
- Read, in order: this file →
docs/adr/0033-sql-dml-grammar.md(esp. §7 cascade + §2 UPDATE, already shipped) →docs/plans/20260520-adr-0033-phase-3.md"Sub-phase 3f" →CLAUDE.md. - Baseline:
cargo test # expect 1524 passing / 0 failing / 1 ignored cargo clippy --all-targets -- -D warnings # clean - Start 3f per §4. Mirror
sql_update.rsfor the grammar anddo_sql_updatefor the worker shell; the new work is the cascade pre-count + the sharedformat_cascade_summaryrefactor- multi-table persistence. Use the
table_namerole for the DELETE target. Capture the WHERE clause text via the firstwheretoken (tractable for DELETE — §4).
- multi-table persistence. Use the
- Do the DA pass for real (§2). On a sub-phase this size, expect to find at least one untested branch or edge case; if you don't, look harder.
- Escalate anything not settled in ADR-0033 / the plan.