28 KiB
Session handoff — 2026-05-08 (5)
Fifth handover for what's been a long day. The previous
session (handoff-4) shipped pretty-table rendering, the
B2/C2 column ops, and designed ADR-0017. This session
implemented ADR-0017 in full, drafted and implemented a
new ADR-0018 covering auto-fill semantics for serial and
shortid, and landed a small parser-error tiny-win along
the way. The user is busy for a while; the next agent
session can pick up several well-scoped tasks listed in
§"Independent work" without further input.
State at handoff
Branch: main. Working tree clean. 4 commits ahead
of origin/main (the 1 ADR design commit from
handoff-4's last action plus 3 from this session). Push
remains the user's call.
Commits since handoff-4:
5bb0a14 ADR-0018 implementation: auto-fill contracts for serial
and shortid
7dfa718 parser: structural error rendering, source echo, and
caret pointer
00947b9 ADR-0017 implementation: per-cell type-change with
override flags
545cbf5 Handoff doc for end of 2026-05-08 (#4)
c3e5f90 ADR-0017 + ADR-0002 amendment: type-change compatibility
+ engine-agnostic posture
Tests: 534 passing, 0 failing, 0 skipped (up from 449 at handoff-4's baseline; +85 over this session). Test counts per phase:
- ADR-0017 implementation: +68 (449 → 517)
- Parser tiny-win: +2 (517 → 519)
- ADR-0018 implementation: +15 (519 → 534)
Clippy: clean with nursery lints enabled.
Release build: ~7.2 MB single binary (up ~100 KB from handoff-4's 7.1 MB; the increase is the type_change matrix module and ADR-0018 auto-fill paths).
What's implemented (delta vs. handoff-4)
The previous handoff covered: Iter 1–6 of track 2,
pretty-table rendering, B2/C2 column ops, optional
to/table parser polish, and silent-rebuild banner
suppression. This session adds:
ADR-0017 (column type-change compatibility) — implemented
Replaces the placeholder "trust STRICT" body of
do_change_column_type with the full per-cell transformer
matrix from ADR-0017. New module src/type_change.rs
(~620 lines + 56 unit tests) carrying:
CellOutcome { Clean(Value) | Lossy { new, reason } | Incompatible { reason } }plustransform_cellcovering every entry in ADR-0017 §3.static_refusalfor same-type / blob / date↔datetime / cross-domain refusals.
change column [in] [table] <T>: <col> (<newtype>) now
accepts --force-conversion (accept lossy) and
--dont-convert (skip the entire client-side layer; let
the engine's STRICT typing decide). Mutually exclusive at
parse time.
Refusal preconditions per ADR-0017 §4:
- Outbound FK (column is a child-side FK): refused outright.
- Inbound FK (column is parent-side / referenced): refused
only when
old_ty.fk_target_type() != new_ty.fk_target_type(). - Post-transformation uniqueness check for any column that carries a UNIQUE constraint in the new schema (PK + ADR- 0018's added serial/shortid).
Diagnostic refusals render through ADR-0016's pretty-table
renderer — bordered, capped at 100 rows with … and N more
inside the box, identifying rows by their PK value(s) per
the ADR-0017 §7 amendment we added (PK identifiers replace
positional row indices, since SQLite returns rows
unordered).
[client-side] success note (§6) fires when any cell was
non-identity transformed; lossy variant adds the lossy
count under --force-conversion.
ADR-0017 §3 was amended in place to add serial → int as
an always-clean matrix entry (it was missing despite §4.1
treating it as the canonical PK conversion).
Parser: structural error rendering + source echo + caret
The old humanise() rendered chumsky's terse default
("found 'i' expected ':' (near i)") as-is and added a
not-helpful (near X) suffix. Now humanise() reads the
structured RichReason::ExpectedFound, lists the
expected patterns in plain prose, prefixes the consumed
context, and produces messages like:
parse error: after `change column Rich`, expected `:`,
found `in`
dispatch_dsl additionally echoes the source line on
parse failure (matching the success path's "running: …")
and prints a ^ caret under the failure position.
Known limit captured for future work: chumsky
combinators in keyword_ci emit Rich::custom errors on
mismatch, which are opaque to chumsky's choice-aggregation
machinery. Result: errors like "expected data or table"
(bison-equivalent) aren't yet possible — only one
alternative shows up. A structural fix to keyword_ci
would aggregate properly. Deferred to a future "parser-as-
source-of-truth" ADR (covered in §"Pending" below).
ADR-0018 (auto-fill contracts for serial and shortid) — designed and implemented
User noticed three asymmetric gaps during ADR-0017 testing:
serialwas restricted to single-column PK. Other RDBMS (PostgreSQLSEQUENCE, MySQLAUTO_INCREMENT) don't have this restriction; ours was an artefact of SQLite's only free auto-increment mechanism (INTEGER PRIMARY KEYrowid alias) leaking into the user-facing surface.text → shortidround-trip worked end-to-end (per ADR-0017's matrix);int → serialwas statically refused.add column T: x (shortid)on a non-empty table left existing rows NULL — violating the design contract that shortids are unique non-null identifiers.
ADR-0018 generalises both auto-generated types with the unifying principle: auto-generated column types honour their generation contract on every path that creates or transitions the column. Concretely:
serialis no longer PK-restricted. Non-PK serial columns get an emitted UNIQUE constraint and use application-sideMAX(col) + 1at INSERT time. PK case unchanged (rowid alias). Implementation switch hidden per ADR-0002.shortidauto-fill at column-materialisation time.add column T: x (shortid)on a non-empty table now generates fresh shortids for existing rows in the same rebuild transaction.change column → shortiddoes the same for null cells.int → serialjoins the matrix as always-clean identity. Other source types refused with a route-via- int hint.change column → serialauto-fills null cells with sequence values continuing fromMAX + 1.- UNIQUE story: non-PK serial / shortid gain UNIQUE on
creation/conversion. Reverse direction (
serial → int,shortid → text) leaves UNIQUE in place — user can drop it later when the constraint-management surface lands (C3-track work, deferred).
ADR-0018 implementation pulled C3 partially forward:
schema_to_ddl gains UNIQUE-clause emission, read_schema
gains UNIQUE detection via pragma_index_list /
pragma_index_info, and ColumnSchema (persistence)
gains a unique: bool field that survives the YAML round-
trip. The user-facing constraint surface (add unique
syntax, drop/rename UNIQUE, multi-column UNIQUE) stays
deferred — only the internal infrastructure required by
the auto-generated type contracts landed.
[client-side] notes extended: when both ADR-0017
transformation AND ADR-0018 auto-fill apply in the same
operation, two distinct note lines emit (e.g.,
change column T: x (shortid) from text where some cells
had to be validated and others auto-filled).
AddColumnResult is a new return type carrying pre-
rendered [client-side] note lines for the new auto-fill
paths.
Engine-vocabulary cleanup
While in do_add_column, fixed an existing user-visible
string that named "SQLite's ALTER TABLE" — an ADR-0002
posture violation that pre-dated this session. The
refusal it lived in was being lifted anyway as part of
ADR-0018, so the leak went with it. A broader engine-name
sweep is listed in §"Independent work" below.
ADR index (read these before touching the related areas)
0000 Record architecture decisions (process)
0001 Language and TUI framework (Rust + Ratatui)
0002 Database engine
— User-facing posture (no engine name in user-visible
strings; amended in handoff-4's session)
0003 Input modes and command dispatch
0004 Project file format
— amended by 0015
0005 Column type vocabulary
— definition of `serial` generalised by ADR-0018 (no
longer restricted to PK; implementation hidden)
0006 Undo snapshots and replay log (deferred)
0007 Sharing and export
— amended by 0015 amendment 1
0008 Testing approach
0009 DSL command syntax conventions
0010 Database access via worker thread
— load-bearing for ADR-0018 §5's MAX+1 INSERT path
safety
0011 FK column type compatibility
0012 Internal metadata for user-facing column types
0013 Relationships, naming, and rebuild-table strategy
— primitive carries every auto-fill-on-rebuild case
0014 Data operations, value literals, and auto-show
— INSERT-time auto-fill amended by ADR-0018 §5
0015 Project storage runtime
— ColumnSchema gained `unique: bool` for ADR-0018's
round-trip (no migration needed; older project
files default to `unique: false`)
0016 Pretty table rendering for data and structure views
— used by ADR-0017's diagnostic tables
0017 Column type-change compatibility
— IMPLEMENTED (this session). §3 + §7 amended in place
for serial→int matrix entry and PK-based row
identifiers. §3 + §4.3 further amended by ADR-0018
for int→serial entry and uniqueness-check extension.
0018 Auto-fill contracts for serial and shortid columns
— IMPLEMENTED (this session). Generalises serial
beyond PK; tightens shortid contract; pulls forward
internal UNIQUE infrastructure.
Pending — proposed next moves (in order)
1. Independent work for next session — see dedicated section below
This is the substantive output for an unattended agent session. Three Tier-A and two Tier-B items are detailed in §"Independent work for next session".
2. Friendly error layer (H1) — needs a small ADR first
ADR-0002's user-facing posture commits to never exposing
engine error text verbatim. The current friendly-message
helper just calls Display. ADR-0017's --dont-convert
path has a tiny local wrapper
(friendly_change_column_engine_error) that recognises
common kinds — when H1 lands, that helper folds into the
broader translator. ADR scope: defining the translation
mapping (which engine error patterns map to which user-
facing wording), how to surface FK / NOT NULL / type-
mismatch errors symmetrically. Probably 200 lines of code
- tests once the ADR settles.
3. Parser-as-source-of-truth ADR
Discussed in this session: chumsky gives us structural information (expected sets, span-tagged AST, partial parses on failure) we're not extracting. That feeds H1a (syntax help in parse errors), I3 (tab completion), I4 (syntax highlighting), and on-the-fly error squiggles. The parser tiny-win this session was a down payment; the broader ADR maps out what we extract from one source (chumsky's parse output) to drive each affordance.
The specific keyword_ci structural-error rework (so
"expected data or table"-style messages aggregate
across choice alternatives) is the load-bearing piece.
4. Query DSL ADR + implementation
Biggest remaining design piece. Earlier discussion
landed on extending show data into a SELECT-style
command with WHERE / projection / order; expose
generated SQL as a pedagogical hook; bundle C5a's
complex WHERE into one coherent feature. Then QA1
(EXPLAIN QUERY PLAN) becomes meaningful.
5. Bigger UX projects
- V4 (session log + Markdown export).
- V1/V2 pretty-rendering refinements (relationship rendering ADR — the "two structures + arrow" view).
- V3 (ER diagram export).
Independent work for next session
These are well-scoped tasks an agent can pick up and finish without user input. Each is sized to fit in one session.
A1. CI workflow (TT5)
Scope: single GitHub Actions YAML at
.github/workflows/ci.yml. Cross-platform Linux / macOS /
Windows; cargo test + cargo clippy --all-targets -- -D warnings. Locks in the 534-test green baseline.
Why independent: no design questions, no codebase integration. Standard Rust CI template adapted to this project's nursery-clippy posture.
Done when: workflow file exists, syntax-validated,
runs on the next push to main. Local verification not
strictly required but act (if installed) can simulate.
Watch out for: the bundled feature on rusqlite
means SQLite is statically linked; no system-package
install step needed. tokio works on all three
platforms unchanged.
Estimated: 1–2 hours.
A2. Engine-name audit (ADR-0002 posture sweep)
Scope: grep error messages and other user-facing
strings across src/ for "SQLite", "STRICT", "PRAGMA",
"rusqlite", "ALTER TABLE", "CAST" (selectively — CAST
is a legitimate SQL keyword users will encounter, only a
problem when prescriptive). Replace with abstract
"the database" / "the engine" phrasing per ADR-0002.
Why independent: mechanical, well-defined. ADR-0002's "User-facing posture" section is the spec.
Where to look:
DbErrorvariants —Sqlite { message }carries engine-vocabulary; check whetherfriendly_message()needs upgrading.- Help text in
app.rs:1100-1200area. - Error messages constructed via
format!withErr(...)/DbError::Unsupported(...)— search for these. - Unsupported-feature refusals.
Done when: zero matches for "SQLite" / "STRICT" / "PRAGMA" / "rusqlite" in user-reachable strings, AND the test suite still green. Code comments and ADR prose are fair game (they explicitly may name the engine — see ADR-0002).
Watch out for: rusqlite::Error::* variant names that
appear in formatted error messages — those leak the crate
name. Replace with a switch on the error kind.
Estimated: 1–2 hours.
A3. replay command (U4)
Scope: new DSL command replay <path> that reads a
file (typically history.log or a .commands file) and
dispatches each non-comment, non-blank line through the
existing DSL pipeline. On a per-line failure, abort the
replay and report replay failed at line N: <error>. On
success, report replay complete: N command(s).
Why independent: small, well-bounded. The DSL pipeline already exists; this just feeds it lines from a file.
Implementation sketch:
- Parser:
replaykeyword followed by a quoted or bare path. The path lexing might need a small new helper (current parser doesn't have a "file path" terminal). - Command AST:
Command::Replay { path: String }. - Runtime: read file, iterate lines, parse-and-execute each, abort on first failure. Probably best kept transactional at the file level (no individual command commits if any later one fails) — but that's a design question worth flagging in the implementation. Default to "stop on first error, report line number, don't roll back": matches the "I'm replaying my history" mental model where partial replay is a recoverable state.
- AppEvent + handler for replay outcome.
- Tests: happy path (3-line replay), failure-mid-replay
(reports line number + stops), empty file, blank lines
skipped, comment lines (
# ...) skipped.
Watch out for: ADR-0015's history.log format — entries
are append-only DSL command lines. replay history.log
on a project should reproduce its current state if started
from an empty database. That's the implicit invariant the
test suite should prove.
Estimated: 3–4 hours.
B1. Update help text for ADR-0017 + ADR-0018 features
Scope: the in-app help command's output (in app.rs,
the do_help or similar function around line 1100–1200)
shows DSL command shapes. ADR-0017 added --force- conversion and --dont-convert flags (already added to
help). ADR-0018 changed semantics of add column ... (serial|shortid) on non-empty tables (now auto-fills
existing rows + emits UNIQUE) — this isn't called out
anywhere user-facing.
Why independent: the ADRs spell out the behaviour; the help text just needs to surface it.
Suggested additions:
add column ... (serial|shortid)line gains a sub-line:(existing rows auto-filled with sequence/generated values).change column ... (serial|shortid)similarly.- New section "Auto-generated types" explaining serial and shortid in 3-4 lines.
Done when: the help output describes the behaviour matching ADR-0018 + ADR-0017. Existing help-output tests pass (some may need string-matching updates).
Estimated: 30 min.
B2. Test gap: change_column → bool from int 0/1
Scope: the type_change matrix has (Int, Bool) per-
cell-classified (clean for 0/1, incompatible otherwise).
This is well-tested at the matrix unit-test level. But
there's no end-to-end test in db.rs exercising
change column T: x (bool) from an int column. Trivial
coverage gap to fill.
Why independent: identical pattern to existing change- column tests; just a different type pair.
Suggested test:
change_column_type_int_to_bool_with_zero_one_succeeds: rows with values 0, 1, 0 → success, no client-side note expected (storage class doesn't change).change_column_type_int_to_bool_refuses_other_values: row with value 2 → incompatible refusal.
Done when: 2 new tests pass; total 536.
Estimated: 30 min.
Sharp edges and subtleties (delta vs. handoff-4)
Carried-over edges still apply (sync update, worker
thread, metadata transactions, rebuild-table primitive,
modal infrastructure, project-switch lock dance, [temp]
cleanup guards, persistence ordering, DataResult carries
column_types, output_render is the only place
tabular output should originate). New ones this session:
-
Type::Serialno longer implies PK at the type system level. ADR-0018 generalised serial. Existing references to "serial" in code comments may say "PK type" — those are stale. The non-PK serial path is active and tested. -
add columnreturnsAddColumnResult, notTableDescription. Tests that calleddb.add_column(...).await.unwrap()and used the result as a description directly need.descriptionindirection. Five existing tests were updated; new tests should follow the new shape. -
ChangeColumnTypeResult.client_sideis nowOption<ClientSideNote>whereClientSideNotecarriestransformed,lossy,auto_filled,auto_fill_kind. When auto-fill happens (target is serial/shortid + null cells), the note fires even thoughtransformedis 0. The filternote.transformed > 0 || note.auto_filled > 0is the canonical "should we emit a note" test. -
Non-PK serial INSERT auto-fill happens via
MAX(col)+1. Per ADR-0010, the worker-thread serialisation makes this safe without explicit locking. If you ever extract the worker thread or change the connection model, this is one of the things that breaks. -
schema_to_ddlemits inlineUNIQUEfor non-PK columns flagged unique. PK columns aren't separately marked unique inReadColumn(PK already implies it); the schema_to_ddl filterunique && !primary_keymatters. -
read_schemareads UNIQUE viapragma_index_listfiltered toorigin = 'u'. Compound UNIQUE constraints are deliberately ignored (ADR-0018 OOS-6 / future C3). If you ever add multi-column UNIQUE support, the detection logic needs extending. -
Parse-error messages now show grammar-derived expected/found and a consumed-context prefix. Existing tests that asserted on the old message shape may have needed updates — none did, since the structural-error tests assert on substrings (the consumed context, the expected token).
Repository layout (delta vs. handoff-4)
src/
type_change.rs — new (ADR-0017)
db.rs — many additions:
AddColumnResult, ChangeColumn
TypeResult, ClientSideNote,
AutoFillKind, ReadColumn.unique,
read_unique_columns,
schema_to_ddl UNIQUE emission,
do_add_plain_column / do_add_auto_
generated_column,
do_change_column_type rewrite,
run_change_column_with_dry_run +
fill_auto_generated_cells,
generate_shortid_batch,
format_auto_fill_add_note,
diagnostic helpers (lossy /
incompatible / collision)
dsl/
parser.rs — change_column flag parsing,
RichPattern-aware humanise,
identifier .labelled,
consumed-context rendering
command.rs — ChangeColumnMode enum
value.rs — validate_date / validate_datetime
made pub(crate) so type_change
can consume them
app.rs — handle_dsl_change_column_success,
handle_dsl_add_column_success,
source-echo + caret on parse fail
event.rs — DslChangeColumnSucceeded,
DslAddColumnSucceeded
output_render.rs — render_diagnostic_table public,
Alignment public,
numeric_alignment_for public
persistence/
mod.rs — ColumnSchema.unique
yaml.rs — write_column emits unique flag,
RawColumn parses it
csv_io.rs — test fixture updated
runtime.rs — CommandOutcome::ChangeColumn
+ AddColumn variants
docs/
adr/
0017-column-type-change-compatibility.md
— §3 (serial→int row), §7 (PK
identifiers) amended
0018-auto-fill-contracts-for-serial-and-shortid.md
— new (this session)
README.md — indexed
handoff/
20260508-handoff-5.md — this file
How to take over
- Read this file.
- Read
CLAUDE.mdfor the working-style rules. - Read
docs/requirements.mdfor granular progress. - If picking up an Independent work item (§A1–B2): read just that item plus the listed ADR section it refers to. The items are scoped to be independently tackleable.
- If working on H1 / Query DSL / Parser-as-source-of- truth: start with an ADR draft. Don't implement without one — those touch enough code to warrant the discipline.
- Run
cargo testto confirm the 534-test green baseline. cargo clippy --all-targetsto confirm clippy-clean.cargo run --releaseto see the UI.
End-to-end smoke test (current state)
Demonstrates ADR-0017 + ADR-0018 features. Replaces the
handoff-4 recipe (which is now stale — change column
under ADR-0017 emits [client-side] notes the previous
recipe didn't show).
$ rm -rf /tmp/handoff5-smoke
$ rdbms-playground --data-dir /tmp/handoff5-smoke
# Inside the app:
help -- help text
(B1: extend with
ADR-0018 wording)
create table Customers with pk id:serial
add column Customers: Name (text)
add column Customers: Score (int)
insert into Customers ('Alice', 10)
insert into Customers ('Bob', 20)
insert into Customers ('Carol', 30)
show data Customers -- pretty-table render
# ADR-0017 type-change with [client-side] note:
change column Customers: Score (real)
-- emits:
-- [client-side] 3 row(s)
-- were transformed before
-- being stored. ...
# ADR-0017 lossy refusal:
change column Customers: Score (int)
-- emits a bordered
-- diagnostic table
-- listing the lossy rows
-- by PK; suggests
-- --force-conversion.
change column Customers: Score (int) --force-conversion
-- succeeds with both
-- "transformed" and
-- "lossy" counts in note.
# ADR-0018 add column auto-fill:
add column Customers: Tag (shortid) -- emits:
-- [client-side] 3 row(s)
-- given auto-generated
-- shortid values. ...
show data Customers -- Tag column populated
# ADR-0018 non-PK serial INSERT auto-fill:
add column Customers: Seq (serial) -- emits another
-- [client-side] note
insert into Customers ('Dave', 40) -- Seq auto-fills 4
-- (MAX of existing
-- 1,2,3 plus 1)
# ADR-0018 int -> serial round-trip:
add column Customers: Counter (int)
update Customers set Counter=1 where id=1
update Customers set Counter=2 where id=2
update Customers set Counter=3 where id=3
update Customers set Counter=4 where id=4
change column Customers: Counter (serial)
-- succeeds (no auto-fill
-- needed since values
-- are unique non-null)
# ADR-0017 PK FK-cascade refinement:
add column Customers: Email (text)
update Customers set Email='alice@example.com' where id=1
update Customers set Email='bob@example.com' where id=2
update Customers set Email='carol@example.com' where id=3
update Customers set Email='dave@example.com' where id=4
change column Customers: id (int) -- serial -> int on PK,
-- no inbound FK ->
-- allowed.
change column Customers: id (serial) -- int -> serial round
-- trip succeeds.
# Parser tiny-win demo:
change column Tag in Customers: Tag (text)
-- typo: column-name-
-- first. Error now reads
-- "after `change column
-- Tag`, expected `:`,
-- found `in`" with caret
-- under the offending
-- character.
quit
Manual spot-checks worth running
--helplists all column ops (drop / rename / change) with their flags.- Pretty rendering kicks in for
show dataAND every schema-mutating command's auto-show. change column T: c (real)succeeds and emits the[client-side]note for any non-empty table where the source values differ in storage class from the target.change column T: c (real) --force-conversionaccepts fractional → int truncation; the note carries both counts.change column T: c (real) --dont-convertbypasses the client-side layer entirely (no[client-side]note, even if all cells transformed cleanly).add column T: x (shortid)on a non-empty table fills every existing row'sxwith a generated shortid.add column T: x (serial)on a non-empty table fills with 1..N. Subsequent inserts get N+1, N+2…- Non-PK serial UNIQUE:
update T set Seq=1 --all-rows→ engine refuses with a unique-violation diagnostic. - Save/load round-trip: create a non-PK serial column, quit, re-open. Read back: column is still UNIQUE.
change column id (int)on aserialPK with no inbound FKs → allowed (per ADR-0017 §4.1 refinement).change column id (text)on aserialPK with an inbound FK → refused (per ADR-0017 §4.1 — fk_target_type would change).