19 KiB
Session handoff — 2026-05-23 (32)
Thirty-second handover. A long, productive session that pushed
ADR-0033 Phase 3 from 3f through 3i (DELETE, RETURNING, UPSERT,
the three DML diagnostics), wrote ADR-0034 (history journal),
fixed a pre-existing self-referential-cascade bug, and closed with a
/runda adversarial round that found six latent
INSERT-target-scope false-positives.
The next session should start sub-phase 3j (dispatch wiring —
make insert/update/delete shared DSL/SQL entry words and remove
the dev scaffold words), then 3k (verification sweep). See §4.
§1. State at handoff
Branch: main. Tests: 1613 passing, 0 failing, 1 ignored.
Clippy: clean (cargo clippy --all-targets -- -D warnings).
Commits this session (newest first). origin/main is at
6b8888f (3h), so the six 3i commits are local-only:
8d17583 walker: 3i /runda DA round — fix INSERT-target scope confusion (6 cases) (local)
4fa0aa0 db+walker: 3i DA pass — not_null PK false-positive fix + arity hardening (local)
cfd925c grammar+db: 3i — DML column-existence + cross-cut verification (local)
2d1112d grammar+db: 3i — not_null_missing diagnostic + TableColumn constraints (local)
6db8253 grammar+db: 3i — insert_arity_mismatch diagnostic (ADR-0033 §8.1) (local)
be63315 grammar+db: 3i — auto_column_overridden diagnostic (ADR-0033 §8.2) (local)
6b8888f grammar+db: 3h — UPSERT ON CONFLICT DO NOTHING / DO UPDATE (origin/main)
fd8b74b grammar+db: 3g — RETURNING on INSERT/UPDATE/DELETE (ADR-0033 §5)
b935090 docs: ADR-0034 — history.log as a complete journal; replay reads ok-only
62f09be db: fix self-referential cascade over-count + SQL-delete render test
2c86a13 grammar+db: 3f — SQL DELETE + cascade summary (ADR-0033 §1/§7)
(Unpushed commits are a normal working state; pushing is the user's step — do not prompt about it.)
§2. Process lessons (read first)
-
/runda(a forced extra DA round) earned its keep. After I reported 3i "done with a thorough DA pass," the user invoked/runda. Probing the interaction of the new 3i diagnostic passes with the existingschema_existencepass underINSERT … SELECTsurfaced six false-positive bugs I had missed (§3i below). The lesson: my first DA pass tested each new pass in isolation; the bugs lived in the cross-cut between new and old passes on a compound input. Always exercise new diagnostics against the awkward compound forms (INSERT … SELECT … ON CONFLICT … DO UPDATE … RETURNING), not just the simple ones. -
Probe-then-assert works. Each
/rundabug was confirmed with a throwaway test asserting the correct behaviour, watching it FAIL, then fixed, then the test kept. Don't fix on suspicion. -
Escalate ADR-mechanism mismatches; don't silently "improve". Three times this session the implementation revealed the ADR's prescribed mechanism was wrong or insufficient (3f cascade, 3h
excludedscoping, history logging). Each was escalated to the user and resolved by ADR amendment / a new ADR / a recorded decision — never silent drift. The user values this; keep doing it.
§3. What shipped this session (detail)
3f — SQL DELETE + cascade (2c86a13) + self-ref fix (62f09be)
src/dsl/grammar/sql_delete.rs(FROM <table> [WHERE] [;]),Command::SqlDelete,Request::RunSqlDelete,do_sql_delete.- ADR-0033 Amendment 2 (written this session): §7's WHERE-injected
pre-count rested on a false premise (it claimed the DSL
do_deletebuilds pre-counts from the typedExpr— it does not; it uses before/after child row-count diffing). The pre-count would also have broken §2's parity promise (reportingSET NULLthe DSL path doesn't). Replaced by mirroringdo_delete's count-diff exactly → exact parity, no WHERE extraction, risk R2 withdrawn.ON DELETE CASCADErow removals only; SET NULL deferred for both paths. Render shared viaCommandOutcome::Delete. - Self-ref cascade bug (pre-existing in
do_delete, surfaced by 3f's DA): a self-referentialON DELETE CASCADEFK conflated the directly-deleted rows with cascade rows. Fixed in bothdo_deleteanddo_sql_delete: whenrel.other_table == target,rows_changed -= rows_affected.
3g — RETURNING (fd8b74b)
- Shared
RETURNING_CLAUSE(reuses Phase-2PROJECTION_LIST, madepub(crate)); optional tail on all three SQL DML shapes. returning: boolon the threeCommand::Sql*variants (per ADR §5), set by the ast-builders, threaded Request→client→worker.run_returning(db.rs) collects the returned rows as aDataResult(a RETURNING DML mutates and yields in onequery_map); reusesresolve_select_column_types.DeleteResultgained adatafield, rendered alongside the cascade summary.- Follow-set fix:
returningadded to the table-source and projection bare-alias follow-sets (sql_select.rs), so anINSERT … SELECT … FROM t RETURNING …stops before RETURNING instead of reading it ast's alias. - Auto-fill × RETURNING:
build_sql_insert'srow_sourcenow stops before the first trailing clause (ON CONFLICT or RETURNING), and the shortid-auto-fill rewrite re-appends the whole trailing tail so generated ids surface inRETURNING *.
3h — UPSERT ON CONFLICT (6b8888f)
on_conflict_clauseonSQL_INSERT_SHAPE: optional(col,…)target (distinctconflict_target_columnrole so it never enterslisted_columns),DO NOTHING/DO UPDATE SET … [WHERE …].dois factored OUT of the action Choice (DO ( NOTHING | UPDATE … )) because a Choice with branches sharing adoprefix trips thewalk_seq/walk_choiceinteraction (ADR-0033 Amendment 1) — a branch matchingdothen failing its 2nd token returns a hardFailed, blocking fall-through. Re-learn: anyChoicewhose branches share a leading token must factor it out.- Worker runs the UPSERT verbatim (SQLite-native); no new exec path.
excludedpseudo-table: resolves to the target's columns inside the DO UPDATE action and completes atexcluded.|, but stays flagged outside it. Mechanism deviation (recorded): the plan prescribed afrom_scopeTableBindingpush; I used byte-range scoping in the diagnostic pass (upsert_excluded: (target, do_update_start, returning_or_end)) +current_table_columnsin completion. Same behaviour, no walker scope-frame change.
3i — three DML diagnostics + cross-cut (be63315→8d17583)
All under ADR-0033 §8. Each diagnostic is a separate post-walk pass in
src/dsl/walker/mod.rs, wired into walk next to
schema_existence_diagnostics. Catalog keys in
src/friendly/keys.rs + src/friendly/strings/en-US.yaml.
auto_column_overridden(WARN, §8.2) —dml_auto_column_diagnostics: an explicit value for aserial/shortidlisted column.insert_arity_mismatch(ERROR, §8.1) —dml_insert_arity_diagnostics: per-tuple VALUES arity (each offending row emits its own diagnostic; the walk stops at the first depth-0 keyword soON CONFLICT/RETURNINGparens aren't mis-counted) + INSERT…SELECT projection arity (plain SELECT; WITH-prefixed deferred to avoid false positives).not_null_missing(WARN, §8.3) —dml_not_null_missing_diagnostics: omitted NOT-NULL-no-default column; excludes serial/shortid (auto-filled) and defaulted columns. Needed a data-model extension:TableColumn(inSchemaCache) gainednot_null+has_default(with aTableColumn::newconstructor), populated inbuild_schema_cachefromColumnDescription. DA fix: the cache builder must usec.notnullALONE, not|| c.primary_key— anintPK is anINTEGER PRIMARY KEYrowid alias that auto-fills, so treating any PK as required false-flags it.dml_target_column_diagnostics(ERROR) — validatesinsert_column/update_set_column/conflict_target_columnagainst the INSERT target (the cross-cut "schema-existence on INSERT VALUES" gate item; also closed DA finding #12, the UPSERT DO UPDATE SET divergence).
The /runda round — INSERT-target scope confusion (commit 8d17583)
One root cause, six manifestations, all pre-existing
false-positives. The INSERT target is recorded under role
insert_target_table, NOT as a diagnostic bindings entry. So refs
that should resolve to the target row were checked against the
statement's bindings — which for an INSERT … SELECT are the
SELECT's source tables. New helper bare_ref_insert_target
re-scopes a ref onto the target when it sits in a target-referencing
region (the DO UPDATE byte range, or an INSERT's RETURNING list).
Applied to: (1) insert column list, (2) ON CONFLICT target,
(3) DO UPDATE SET-RHS / WHERE bare refs (also closed #12's residual),
(4) RETURNING bare refs, (5) target-qualified t.col, (6)
target-qualified t.*. Each has positive + negative tests.
§4. Sub-phase 3j — the NEXT job (dispatch wiring)
Read docs/plans/20260520-adr-0033-phase-3.md "Sub-phase 3j" and
ADR-0033 Amendment 1 (the category-grouped dispatch mechanism —
this is the machinery 3j uses).
Goal: make insert / update / delete shared DSL/SQL entry
words and remove the dev scaffold words (sqlinsert,
sql_update, sql_delete).
Current state of the scaffold (what 3j removes/rewires):
src/dsl/grammar/data.rs:SQL_INSERT(entry wordsqlinsert),SQL_UPDATE(sql_update),SQL_DELETE(sql_delete) CommandNodes, registeredAdvancedinREGISTRY(src/dsl/grammar/mod.rs). Their ast-buildersbuild_sql_insert/build_sql_update/build_sql_deletereconstruct the real keyword (format!("insert {tail}")etc.) — once the real entry word is shared, these collapse tosource.trim()(the dev-word prefix reconstruction goes away).- The DSL CommandNodes
data::INSERT/UPDATE/DELETE(entry wordsinsert/update/delete) areSimple.
The mechanism (ADR-0033 Amendment 1): dispatch is
category-grouped in walker::walk. Each REGISTRY entry carries
a CommandCategory::{Simple, Advanced}. A shared entry word has a
node in BOTH groups — e.g. a Simple DSL insert node and an
Advanced SQL insert node, both entry word insert. Selection:
- Simple mode → only
Simplecandidates; if none but anAdvancedexists, emit theadvanced_mode.sql_in_simplehint. - Advanced mode → try
Advancedcandidate(s) first, then theSimplecandidate as fallback (delete from t where id=1→ SQL;delete from t --all-rows→ falls through to DSL).
3j scope (from the plan):
- Register the SQL shapes under the real entry words
insert/update/deletewith categoryAdvanced(alongside the existingSimpleDSL nodes). The dispatcher already supports two candidates per entry word (Amendment 1 / howselect+withwork). - Remove the
sqlinsert/sql_update/sql_deletedev words + their CommandNodes; simplify the threebuild_sql_*ast-builders tosource.trim()(no keyword reconstruction). - De-duplicate the completion entry-word lists — once a word
appears twice in
REGISTRY(Simple + Advanced), the entry-word candidate lists must not show it twice (flagged in ADR-0033 Amendment 1 consequences; thecommand_for_entry_word/ completion filters inmod.rsare the sites). - Verify: existing DSL insert/update/delete tests still green
(unchanged inputs/outputs); SQL inputs route via the shared word in
Advanced; a structurally-ambiguous input (
delete from t where id = 1) routes SQL-first; a DSL-only input (delete from t --all-rows) falls through to DSL.
3j heads-up — the diagnostics keyed on roles will keep working:
the 3i passes match on roles (insert_target_table, insert_column,
etc.) that come from the SQL shapes, so they fire only when the SQL
shape matched. A DSL insert (different roles) won't trip them. But
re-run the full diagnostic test set after 3j — the dev-word →
real-word switch changes how the path is built (the entry-word offset
/ no keyword-prefix reconstruction), and the ast-builders' source
handling changes.
Tests that hard-code the dev words must be migrated in 3j:
tests/sql_insert.rs, tests/sql_update.rs, tests/sql_delete.rs
use sqlinsert … / sql_update … / sql_delete … inputs and parse
helpers; the walker tests in mod.rs use sqlinsert … inputs. After
3j they become insert … / update … / delete … (Advanced mode).
This is a sizeable but mechanical migration — consider an Explore/
general-purpose sub-agent for the input-string swaps, compile-verified.
§5. Sub-phase 3k — verification sweep (after 3j)
Per the plan: fill the cross-cut verification matrix, produce the
phase-exit verification report at docs/handoff/<date>-phase-3- verification.md, final DA review (genuine, not rubber-stamp), confirm
zero failures / zero skips / no regression from the Phase-2 baseline
(1446/0/1 — note this session's count is 1613 and climbing as tests
were added per sub-phase). Clippy clean.
§6. Decisions settled this session (do not re-litigate)
- 3f cascade = count-diff parity (ADR-0033 Amendment 2),
user-confirmed. SET NULL reporting deferred for
both DSL and SQL paths (preserves parity); a future enhancement
touches both
do_deleteanddo_sql_deletetogether. returning: boolon the Command variants (ADR §5), not a text field — the auto-fill rewrite preserves the trailing tail instead.excludedscoping = byte-range (not the plan's TableBinding push) — recorded as a mechanism deviation; behaviour matches §9.- History journal = ADR-0034 (written, Accepted): see §7.
- not_null =
c.notnullonly (not|| primary_key) — int-PK false-positive avoidance.
§7. Tracked deferred items (nothing lost)
These are parked, not dropped — pick up after Phase 3 (3k) per the user's "we'll take care of the extra tasks when phase 3 is complete".
- TASK #8 — Implement ADR-0034 (history journal).
history.logis success-only, but the in-memory Up/Down recall ring records every submission, and the ring is re-seeded fromhistory.logon open — so failed commands are recallable in-session but lost across sessions. ADR-0034 (docs/adr/0034-history-journal-and-replay- filter.md, Accepted): makehistory.loga complete journal taggedok/err; hydration reads all, replay readsokonly. Code = two sub-tasks (journal-failures+filtering; replay-format). Successful commands stay journalled transactionally by the worker; failures journallederrbest-effort from the runtime/app error path (a parse failure never reaches the worker). Existing all-oklogs need no migration. - TASK #9 — Fix broken replay + add the missing test.
run_replay(src/runtime.rs) parses each whole trimmed line through the DSL parser with NO understanding of the<ts>|<status>|<source>format, soreplay history.logfails on line 1 — despite ADR-0006 / theresolve_replay_pathcomment promising it works. No test feeds the pipe format to replay (thereplay_history_log_records_ subcommands_onlytest only checks what replay writes). Fix:run_replayaccepts BOTH a journal record (extract+unescape source, skip unlessstatus==ok) and a bare command line; detection by the leading timestamp+status prefix. Test-first with a realhistory.log.
Residuals / observations (lower priority, all discussed)
- #12 residual is CLOSED — DO UPDATE SET-RHS / WHERE bare refs are
now validated against the target (the
/rundaround). Task #12 is marked completed. excludedcompletion soft-leak: completion offers the target's columns forexcluded.|even outside DO UPDATE (the diagnostic pass strictly scopes it; completion is the softer surface). Untested, advisory, acceptable.- Old-project migration (from handoff 30 §4): the pre-
check_expr3-column__rdbms_playground_columnsschema; deferred to the migration framework (ADR-0015 Iter 6). - Two-line hint box (ADR-0022 Amendment 2) — deferred.
writes_user_listed_columnflag staysfalse(handoff 31 §7) — VALUES-against-listed-columns completion narrowing, nothing needs it.
§8. Process pins (unchanged, still binding)
- Confirm every commit. Propose the message; wait for the go-ahead. No auto-commit at sub-phase gates (this overrides the plan's standing commit pre-authorization — the user confirmed each commit this whole session).
- 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". The
/rundaround is the model: probe → confirm fail → fix → keep the test. - Core walker changes (
walk_seq/walk_choice/walk_repeated) need explicit user OK before coding. (New node variants and new post-walk passes do not — those were added freely this session.) - Escalate ambiguity / ADR-mechanism mismatches; never classify work out of scope without user confirmation. Resolve mechanism changes by ADR amendment BEFORE coding (3f, 3h, history all did).
/runda(the extra-DA-round skill) is a user-invoked tool — do not invoke it yourself, but internalise its lesson: re-check cross-cut interactions.
§9. How to take over
- Read, in order: this file →
docs/adr/0033-sql-dml-grammar.md(esp. Amendment 1 — dispatch — and Amendment 2 — cascade) →docs/plans/20260520-adr-0033-phase-3.md"Sub-phase 3j" + "3k" →docs/adr/0034-history-journal-and-replay-filter.md(parked) →CLAUDE.md. - Baseline:
cargo test # expect 1613 passing / 0 failing / 1 ignored cargo clippy --all-targets -- -D warnings # clean - Start 3j per §4. The dispatch machinery (Amendment 1,
category-grouped) already exists and is how
select/withwork — 3j adds the SQLinsert/update/deletenodes asAdvancedalongside theSimpleDSL nodes, removes the dev words, simplifies the threebuild_sql_*builders, de-dups completion entry-word lists, and migrates the dev-word test inputs. Expect a sizeable but mechanical test migration (the threetests/sql_*.rs+ walker tests hard-codesqlinsert/sql_update/sql_delete). - Re-run the full diagnostic suite after 3j (§4 heads-up) — the path-shape change is the risk.
- Then 3k (verification sweep + phase-exit report).
- Escalate anything not settled in ADR-0033 / the plan; the user wants mechanism mismatches surfaced, not silently fixed.