19 KiB
Session handoff — 2026-05-15 (11)
Eleventh handover. Continues from handoff-10. This session completed every deferred item from handoffs 9 + 10: ADR-0024 Phase F (full) step 5 (walker-driven completion), the Ranker hook scaffolding, the HintMode dispatch layer, Phase D (full) end-to-end schema-aware value typing, and the round-5 optional-suffix completion gap. Six commits total (five feature commits + this handoff).
The codebase now reaches the steady-state ADR-0024 envisioned: one declaration per command, walker as single source of truth, typed value slots rejecting mis-shaped input at parse time.
State at handoff
Branch: main. Working tree clean. Local HEAD is
8188fa5, ahead of origin/main by ten commits across two
sessions (user pushes asynchronously).
Commits since handoff-10's baseline (044173b):
bbe1252 ADR-0024 Phase F (full) step 5: walker-driven completion
7ae1a0f ADR-0024 ranker hook scaffolding
8581779 ADR-0024 HintMode dispatch via walker_hint_mode_at_input
abebd79 ADR-0024 Phase D (full): schema-aware value typing
8188fa5 ADR-0024 round-5 follow-up: surface tail-Optional expectations
Tests: 830 passing, 0 failing, 1 ignored (up from 806
at handoff-10's baseline). Net +24 — these all under
dsl::walker::tests::phase_d_*, dsl::walker::tests::hint_mode_*,
completion::tests::ranker_*, and the round-5 optional-suffix
tests. The ignored test is still the same \``ignoredoc-test insrc/friendly/mod.rs`.
Clippy: clean with nursery lints + -D warnings.
What shipped this session
Step 5 — Walker-driven completion (commit bbe1252)
The completion engine no longer round-trips through the
ParseError::Invalid::expected: Vec<String> bridge. New helper
walker::expected_at_input(source) -> Vec<Expectation> returns
structured expectations with full IdentSource + role info
attached. The completion engine pattern-matches Expectation
variants directly:
Word(w)/Literal(s)→ keyword candidates (alphabetic filter).Ident { source: IdentSource::Types }→ type names.Flag(body)→--{body}candidates.Literal("1")→ composite-literal opener (1:n).Ident { source, .. }wheresource.completes_from_schema()→ schema identifier candidates.
is_value_literal_signature now matches on Expectation::Word
literals + NumberLit + StringLit variants instead of
backticked strings. invalid_ident_at_cursor and
typing_name_at_cursor adopt the same structured path.
Zero observable-behaviour change: every existing completion test passes unchanged. The win is clean code and lossless information flow — the chumsky-era string round-trip is gone.
Ranker hook (commit 7ae1a0f)
ADR-0024 §ranker-layer scaffolding:
pub type Ranker = fn(Vec<Candidate>) -> Vec<Candidate>;pub const fn identity_ranker(c) -> Vec<Candidate>returns its input unchanged.candidates_at_cursor_with(input, cursor, cache, ranker)applies a custom ranker; the defaultcandidates_at_cursor(input, cursor, cache)delegates withidentity_ranker.
Three tests cover identity preservation, custom reordering, and the empty-list-collapses-to-None edge.
No production caller passes a non-identity ranker yet — hooks for frequency-based ranking, content-aware priors, and recency plug in here without touching grammar declarations.
HintMode dispatch (commit 8581779)
ADR-0024 §HintMode-per-node specified a dispatch layer between the walker's expected-set and the hint-panel renderer. This session lands the dispatch shape:
walker::hint_mode_at_input(source) -> Option<HintMode>pattern-matches the walker's expected-set for the value-literal-slot signature (returnsProseOnly("hint.value_literal_slot")) and forIdent { source: NewName }(returnsForceProse("hint.ambient_typing_name")).input_render::ambient_hintconsults the hint mode before the candidate / prose ladder.ProseOnlyemits the catalog prose at empty prefix;ForceProseconsultstyping_name_at_cursorfor the post-name probe and emits the typing-name prose.- A separate
expected_for_hint(source)returns empty onWalkOutcome::Matchso the hint resolver doesn't surface prose at the end of a valid command (where the completion engine offers optional-suffix candidates instead).
This is the structural shape the ADR specified; the actual
detection inside hint_mode_at_input is signature-matching
against the expected-set (same logic the previous post-hoc
detectors used, relocated to one place). The detection becomes
node-attached in Phase D (already wired below).
HintMode gains PartialEq + Eq for tests; docstring
rewritten to describe live semantics.
8 new walker tests pin the hint resolver across value-literal-slot positions, NewName-slot positions, and the non-fire cases.
Phase D (full) — schema-aware value typing (commit abebd79)
The central design claim of ADR-0024 §Phase D. Insert / update / delete value slots dispatch on the user-facing column type at parse time, rejecting mis-shaped input with localised wording instead of waiting for the bind-time error.
End-to-end pipeline:
- SchemaCache extension (
src/completion.rs): newTableColumn { name, user_type }per-table type metadata stored inSchemaCache.table_columns: HashMap<String, Vec<TableColumn>>.columns_for_table(name)is the case-insensitive lookup helper. - WalkContext schema plumbing (
src/dsl/walker/context.rs):WalkContext<'a>gains a lifetime and aschema: Option<&'a SchemaCache>.WalkContext::new()is the schemaless default;with_schema(s)is the schema-aware constructor. - Schema-aware parse entry point
(
src/dsl/parser.rs):parse_command_with_schema(input, schema)is the new public variant.parse_command(input)delegates withNonefor back-compat. Node::Identwrites_table/writes_column flags (src/dsl/grammar/mod.rs): when set, the walker writes the matched identifier's resolution intoWalkContext.- Walker driver
DynamicSubgrammardispatch (src/dsl/walker/driver.rs): the factory resolves the inner Node at walk time; the result isBox::leaked so its static-slice fields (Choice/Seq) have the lifetime the walker expects (per ADR-0024 §sub-grammars). - Typed value slot factories
(
src/dsl/grammar/shared.rs): one perType—int_slot(integer-only NumberLit + null),real_slot,decimal_slot,bool_slot,text_slot,date_slot,datetime_slot,blob_slot.slot_for_type(ty)is the dispatcher.current_column_value(ctx)readscurrent_columnand dispatches.column_value_list(ctx)readscurrent_table_columnsand emits a Seq of typed slots separated by commas. - Data-command grammar uses the typed slots
(
src/dsl/grammar/data.rs):INSERT_VALUES_LISTbecomesDynamicSubgrammar(column_value_list).UPDATE_ASSIGNMENTandWHERE_CLAUSEusePER_COLUMN_VALUE = DynamicSubgrammar(current_column_value). Insert / update / delete table-name slots setwrites_table: true; update / delete column-name slots setwrites_column: true. - Runtime populates schema-with-types (
src/runtime.rs):refresh_schema_cachecallsdescribe_tablefor each table and populatesSchemaCache::table_columns. Live typing benefits immediately on project load and after every successful DDL. - App dispatches with schema (
src/app.rs):dispatch_dslroutes throughparse_command_with_schema.
Catalog: new parse.custom.bind_type_mismatch entry with
{found} / {expected} placeholders, surfaced by the
int_slot and decimal_slot validators.
Fallback semantics: when the schema can't resolve a table (or
the walker is schemaless), DynamicSubgrammar factories fall
back to the schemaless VALUE_LITERAL choice — the
pre-Phase-D behaviour, so every existing test passes
unchanged.
11 new walker tests cover schema-aware insert / update / delete: typed acceptance per column, decimal rejection at int columns, null acceptance at every slot, multi-assignment per-column dispatch, schemaless fallback.
Round-5 follow-up — tail_expected (commit 8188fa5)
Closes the long-standing "save Tab offers as" gap. Pre-this
session, save parsed as a complete save command, the
walker returned Match { command_idx }, and the completion
engine had nothing to mine.
WalkResult gains a tail_expected: Vec<Expectation> field.
The walker's top-level Matched branch copies the outer
shape's skipped-Optional expectations into it.
expected_at_input returns tail_expected on Match.
Completion now surfaces as (for save ), short /
verbose (for messages ), and any other Optional suffix
expectation as Tab candidates.
hint_mode_at_input deliberately does NOT consume
tail_expected (a separate expected_for_hint returns empty
on Match). Reasoning: completion offering optional suffixes is
helpful ("you could continue with as"); the hint panel
showing prose like "Type a name" at the end of a valid command
would be misleading ("the command is complete, but you're
forcing me to continue").
Two new tests: save_space_offers_as_via_tail_expected,
messages_space_offers_short_and_verbose_via_tail_expected.
What's still pending — for future work
1. CommandNode.help_id not consumed
Every CommandNode declares help_id: Option<&'static str> (e.g.
app.quit, data.insert) pointing into a catalog namespace
that doesn't exist yet. The current help system reads
help.in_app_body — one big hand-curated catalog entry.
Wiring help_id would mean:
- Add 20 per-command help catalog entries.
- Replace the
help.in_app_bodylookup with iteration over REGISTRY → translate eachhelp_id. - The in_app_body composition logic lives in
app::note_help.
Benefit: new commands auto-include in help without YAML edits. Cost: medium refactor; user-facing strings need careful rewording.
Not blocking; not user-visible regression.
2. Box::leak memory growth per dynamic walk
Node::DynamicSubgrammar dispatch uses Box::leak in the
walker driver. Per-walk leak is bounded by command-shape
complexity (~6 Node allocations for a typical
insert into T values (...) with 5 columns). For
per-keystroke completion polling, the leak grows over time.
ADR-0024 §sub-grammars sketched a per-walk arena as the
alternative. Implementation would replace the Box::leak with
a WalkContext::arena: Vec<Box<Node>> that lives for the
walk's duration. Worth doing if memory growth becomes
measurable (it isn't today).
3. Replay path is schemaless
runtime::run_replay calls parse_command(line) without a
schema, so typed-slot rejections don't fire during replay —
bind-time errors still catch the same cases, so this is a UX
nicety not a correctness gap. The schema mutates during replay
(create / add columns), so the simplest "thread schema through"
approach would need a per-line re-fetch.
Worth doing once a user actually trips this. Today it just
means a replay that contains insert into T values (3.14) at
an int column fails at bind time instead of at parse time.
4. HintMode is detection-based, not node-attached
The ADR design says HintMode annotations should live on
grammar nodes (so date_slot could carry
ProseOnly("hint.date_format") and the resolver dispatches on
each expected node's HintMode). Today the resolver
pattern-matches the walker's expected-set signature for the
value-literal slot and the NewName ident slot.
Functionally equivalent — but the structural form would let
typed value slots carry per-type prose hints (a date slot
shows "Type a date as 'YYYY-MM-DD'" instead of the generic
value-literal hint). Phase D's typed slots are ready to receive
the annotations; just plumb HintMode through Expectation when
emitting from Choice / Ident / NumberLit / StringLit
nodes.
5. Ranker is scaffolding-only
No production caller passes a non-identity ranker. Frequency
ranking, content-aware priors, recency hooks — all future
work that plugs in via candidates_at_cursor_with.
6. Dead parse.token.* catalog entries
The five structural-class entries
(parse.token.identifier/number/string_literal/flag/end_of_input)
and three lex-error entries
(parse.token.error.{bad_flag,unknown_char,unterminated_string})
remain in the catalog. Today's walker doesn't surface them
(structural classification feeds highlighting, not catalog
lookup). Carried from handoff-10 §8 — conservative call: leave
in. They cost nothing and reintroducing them would be cheap if
a future need arises.
Sharp edges and invariants
These are the load-bearing decisions worth remembering:
- Phase D fallback is behaviour-preserving. A
DynamicSubgrammarfactory called with no schema in WalkContext, or with a table the schema doesn't know about, returns the schemalessVALUE_LITERALchoice. Every test that calledparse_command(input)without a schema continues to pass with the existing semantics. Box::leakper walk. See pending §2 above.tail_expectedis for completion, not hint. Surfacing optional-suffix expectations through the hint resolver would display prose like "Type a name" at the end of a complete command. The split viaexpected_for_hintenforces this invariant.writes_tableresolves columns at the same site. The walker writescurrent_table = Some(matched)AND resolvescurrent_table_columnsfromschema.columns_for_table. If the schema lookup misses,current_table_columnsis None and downstream DynamicSubgrammar fallbacks engage. The column list is a snapshot at the moment of the table ident match — if the schema cache refreshes mid-walk (it doesn't today), the walk would use the old snapshot. This is a feature, not a bug — schema-cache consistency belongs to the runtime, not the walker.- Schema-aware parse cost. Every
dispatch_dslcall now walks the schema-aware path with a borrowed SchemaCache. The cost is oneHashMap::get(table)per Tables ident write and oneVec::clonefor the column list. Negligible. Repeated(UPDATE_ASSIGNMENT, ',', 1)preserves current_column write semantics. Each repetition writes the matched column name, overwriting the previous iteration's value. The=and the typed value slot dispatch against the most recent write. Correct.
ADR index (delta vs. handoff-10)
0014 Data operations, value literals, and auto-show
— Phase D now rejects mis-shaped values at parse time
instead of at bind time. Same final behaviour; better UX.
0024 Unified grammar tree: execution plan (ACCEPTED)
— A through F (full all 5 steps) + D (full) landed.
HintMode dispatch landed (detection-based; node-attached
form is non-blocking future work).
Ranker hook landed (scaffolding).
Repository layout (delta vs. handoff-10)
Files significantly modified:
src/dsl/grammar/mod.rs — Node::Ident writes_table/
writes_column flags
src/dsl/grammar/data.rs — insert / update / delete use
DynamicSubgrammar value slots
src/dsl/grammar/shared.rs — typed value slots
(int_slot, decimal_slot, …),
slot_for_type,
current_column_value,
column_value_list
src/dsl/walker/context.rs — WalkContext gains lifetime
and schema reference;
current_column changes from
ColumnInfo to TableColumn
src/dsl/walker/driver.rs — DynamicSubgrammar dispatch
via Box::leak; writes_table/
writes_column semantics in
walk_ident
src/dsl/walker/mod.rs — expected_at_input,
hint_mode_at_input,
expected_for_hint;
WalkResult::tail_expected
surfacing on Match
src/dsl/walker/outcome.rs — WalkResult::tail_expected
src/dsl/parser.rs — parse_command_with_schema
src/completion.rs — SchemaCache::table_columns,
columns_for_table, TableColumn;
structured Expectation
consumption throughout;
Ranker / identity_ranker;
candidates_at_cursor_with
src/input_render.rs — HintMode dispatch via
walker_hint_mode_at_input;
hint_leading_slice helper
src/app.rs — parse_command_with_schema in
dispatch_dsl
src/runtime.rs — describe_table loop in
refresh_schema_cache
src/friendly/strings/en-US.yaml — parse.custom.bind_type_mismatch
src/friendly/keys.rs — keys.rs declaration
Files added: none (only new tests inside existing modules).
How to take over
- Read this file.
- Read handoff-10 (
20260515-handoff-10.md) for the prior session's Phase F (full) work that landed steps 1-4 and set the stage. - Read
CLAUDE.mdfor the working-style rules. - Read ADR-0024 for the design intent now fully realised.
- Skim
src/dsl/grammar/mod.rs— Node enum, IdentSource, HintMode, CommandNode contract. - Skim
src/dsl/walker/mod.rs— walk(), expected_at_input, hint_mode_at_input, tail_expected propagation. - Skim
src/dsl/grammar/shared.rs— typed value slots and the two dynamic sub-grammars. - Run
cargo test— should report 830 passing, 0 failing, 1 ignored. - Run
cargo clippy --all-targets -- -D warnings— clean. - If picking up pending items: see §1-§6 above for the open work. §1 (help_id wiring) and §4 (node-attached HintMode) are the most tractable; the rest are background notes more than action items.
End-to-end smoke (post-this-session)
User-visible improvements vs. handoff-10:
- Typed value rejection at parse time. With a populated
schema,
insert into T values (3.14)at an int column fires a parse-time error ("value '3.14' is not a valid integer") instead of waiting for the bind-timebind_type_mismatcherror. Same wording, earlier surface. - Save Tab offers
as. Typesaveand press Tab → the completion menu offersas(closing the round-5 gap). - Messages Tab offers
short/verbose. Same shape. - Walker-direct completion. Same user-visible candidates as before; the round-trip through formatted strings is gone. Future hint improvements (per-column-type prose, frequency-based ranking) plug in without touching grammar declarations.
Everything else is internal — code health, clearer abstractions, fewer surprises for the next maintainer.