Files
rdbms-playground/docs/adr/0027-input-validity-indicator.md
T
claude@clouddev1 0e5f226e6b docs: handoff 19 — ADR-0027 highlight/hint wiring finished
ADR-0027 gains a "Follow-up" section recording the completed
§2 highlight + hint wiring and precise per-literal WARNING
spans; the three stale As-built bullets point at it.
requirements.md test baseline → 1125 and the S6 entry notes
the completion + Amendment 1. handoff-19 records the run and
queues the two deferred manual-testing bugs (add 1:n
relationship completion/usage hint; --resume / last_project)
as the next session's first work.
2026-05-19 09:48:19 +00:00

17 KiB
Raw Blame History

ADR-0027: Input-field validity indicator

Status

Accepted

Context

While the user types, the app already flags problems in two immediate ways (ADR-0022): per-token syntax highlighting, and an ambient hint panel describing the slot under the cursor. Both are local — they speak about the spot being edited.

What is missing is a global signal: a single, glanceable answer to "if I press Enter now, will this command run?" A learner can type something whose error is highlighted ten columns back, move the cursor to the end, and submit it without noticing.

This ADR adds a validity indicator at the right edge of the input row, and — because a meaningful indicator needs a consistent notion of "what is wrong" — a small diagnostics-severity model behind it.

The indicator is advisory. It never blocks submission: that posture is settled (ADR-0022 — the app indicates, it does not refuse; ADR-0026 §7 — even a flagged WHERE still runs if submitted).

Decision

1. Two severities

  • ERROR — the input is known to fail. Either it does not parse (incomplete, or a mismatched / invalid token), or it parses but names something that does not exist (an unknown table or column).
  • WARNING — the input is valid and will run, but is very likely not what a knowledgeable user wants: a type-mismatched comparison, or = NULL (both from ADR-0026 §7). Amendment 1 adds a third trigger — LIKE against a numeric column.

The split is certainty of failure versus likely misleading. The indicator shows the highest severity present; clean input shows nothing at all.

2. The diagnostics model

The walk produces a set of diagnostics alongside its WalkOutcome. A diagnostic is, roughly, { severity, span, message_key } — the severity drives the indicator, the span drives highlighting, the message key drives the hint text (ADR-0019 catalog).

Diagnostics come from three sources, all reachable within the single schema-aware walk:

  • The parse outcome. Incomplete, Mismatch, and ValidationFailed each yield an ERROR diagnostic; a plain Match yields none.
  • Schema existence. A matched IdentSource::Tables or IdentSource::Columns token whose name is absent from the schema cache yields an ERROR diagnostic. This is new behaviour. Today walk_ident returns Matched for any identifier-shaped token and consults the schema only to populate context (current_table, current_column); an unknown table parses cleanly and fails only at execution. The check is cheap — the schema cache is already in WalkContext — but it is genuinely new code, and it must run for every Tables / Columns ident, not only the writes_* ones.
  • Expression flagging (ADR-0026). A type-mismatched comparison and = NULL yield WARNING diagnostics.

The walker is the one schema-aware pass, so diagnostics are emitted there, not in a separate re-resolution pass. WalkResult gains a diagnostics field. The indicator reads the highest severity across the outcome and the diagnostics; highlighting and the hint panel read the individual diagnostics for where and why. The indicator is the summary; the existing layers remain the detail.

3. "Would Enter succeed now?" — and the debounce

The indicator answers exactly one question: if you pressed Enter right now, what would happen? — runs clean (nothing shown), runs but flagged ([WRN]), or will not run ([ERR]).

An incomplete, still-being-typed command is in the [ERR] bucket: pressing Enter on it fails. The indicator does not special-case "still typing".

Flicker is prevented not by suppressing states but by a debounce:

  • On every keystroke the indicator is hidden immediately.
  • After roughly one second with no typing, it reappears, showing the current verdict.

So: typing → blank; a pause → the verdict. The user gets a settled, glance-before-submit signal without a marker that thrashes on every key. Highlighting and hints stay immediate and unchanged — only the indicator's display is debounced; diagnostics themselves are still computed every keystroke to feed those immediate layers.

The indicator's display is therefore time-gated. Per the update()-is-pure invariant, the debounce timer lives in the runtime / event loop; App holds the indicator's visible state, which the runtime sets when the quiet interval elapses or a keystroke arrives. The interval is a single tunable constant (~1 s).

4. Rendering

The indicator is a fixed-width five-column label — [ERR] or [WRN] — or nothing when the input is clean. A positive "all good" state is deliberately omitted: absence is the all-clear.

It sits in the input row, at the right end. The rightmost strip — five columns for the label plus one column of gap, six in all — is reserved unconditionally: the text area is always width 6, whether or not the indicator is currently visible. The label appears and disappears within its already-reserved strip, so the text / horizontal-scroll boundary never moves and the label never collides with the typed command.

A conditionally reserved strip was rejected: it would make a long, horizontally-scrolled command jump up to six columns sideways on every typing-pause transition. Unconditional reservation costs ~6 columns of typing width — negligible — for a stable layout.

Border-mounting the indicator (as chrome on the field's frame, like the mode label) was also considered and not chosen: a right-edge border decoration reads differently from a top-border label, and the in-row position is the intended look.

[ERR] uses the existing theme.error. [WRN] needs a new warning theme colour — an orange / amber — defined for both the light and dark themes (NFR-5: colour conveys information; NFR-7: legible on either background).

5. The indicator never blocks submission

Enter always submits, whatever the indicator shows. A user who wants to run a flagged command — or even a doomed one, to see what the engine does — may; the error lands in the output as usual. The indicator's job is to make "this will not do what you think" visible before submission, not to prevent it. This is the same advisory posture as ADR-0022 and ADR-0026 §7.

6. The existing-cases sweep, and discoverability

Implementation includes a sweep of the current command surface for failures that are detectable before submission but not yet surfaced as diagnostics — chiefly unknown table and column references, across every command that takes an identifier. Each becomes an ERROR diagnostic via §2. (The WARNING set is thin until ADR-0026 lands; type mismatch and = NULL are its first members.)

The diagnostics model is documented as the route for any future "we can tell, before it runs, that this is wrong or dubious" case. This ADR is cross-referenced from the diagnostic-producing code and from related ADRs, so new cases plug into the model rather than becoming one-off checks.

7. Out of scope

  • Blocking submission — never; the indicator is advisory (§5).
  • A positive [OK] state — clean input shows nothing.
  • Raw advanced-mode SQL — there is no SQL parser yet (Q1). The indicator covers simple-mode DSL and the app-level commands the walker parses; when SQL parsing lands, SQL diagnostics route through this same model.
  • Per-diagnostic display in the indicator itself — the indicator is a one-glyph summary; where and why stay with highlighting and the hint panel.

Consequences

  • WalkResult gains a diagnostics field; the walker emits diagnostics (parse-outcome and schema-existence) as it walks.
  • A new schema-existence check on IdentSource::Tables / Columns matches. Small, but genuinely new — today an unknown identifier parses cleanly and fails only at execution.
  • The event loop gains a debounce timer for the indicator's display. It lives in the runtime, so update() stays pure.
  • A new warning theme colour; the input field reserves a fixed six-column right strip.
  • The indicator is advisory; submission is never gated.
  • A reusable diagnostics-severity model that future pre-submit checks — and, eventually, advanced-mode SQL — extend.
  • The WARNING severity has no triggers until ADR-0026 is implemented. The indicator may ship ERROR-only first and gain WARNING with C5a, or ship after C5a; the model carries both severities regardless.

Implementation notes

A sensible order, each step test-guarded:

  1. The Diagnostic type and the diagnostics field on WalkResult; map the parse outcome to diagnostics.
  2. The schema-existence check in walk_ident for Tables / Columns idents.
  3. The warning theme colour; the fixed six-column input strip; the [ERR] / [WRN] rendering.
  4. The debounce in the runtime, and the indicator's visible state on App.
  5. The sweep — confirm every identifier-taking command surfaces unknown-name diagnostics; cross-reference this ADR from the diagnostic sites.

ADR-0026's expression diagnostics (type mismatch, = NULL) land with that ADR's implementation and feed the WARNING severity.

As-built notes (2026-05-19)

All five build-order steps are implemented, and — because ADR-0026 deferred its own step 5 — the ADR-0026 §7 expression diagnostics (type mismatch, = NULL) were folded in here as the WARNING severity's first triggers. Realization choices and deviations from the sketch above:

  • Diagnostics are computed post-walk, not emitted during the walk. §2 says "diagnostics are emitted there [in the walker]". As built, the walk produces the MatchedPath and the Command as before; walk() then runs two post-walk passes — schema_existence_diagnostics over the matched path, and expr_warnings over the built Expr — and stores the result in WalkResult::diagnostics. This keeps diagnostics off the walker's speculative-rollback paths (a Choice branch that matches an ident then rolls back would otherwise leak a diagnostic); the post-walk passes see only the surviving terminals. Same single schema-aware context, one logical pass later.
  • Diagnostic carries a pre-rendered message: String, not the {severity, span, message_key} of §2. Each diagnostic source catalog-translates at creation. This sidesteps the awkwardness of a parse-style message having no single static key, and the model stays "roughly {severity, span, message}".
  • The parse outcome is not a synthetic Diagnostic. The indicator reads it straight from WalkOutcome (§2's "highest severity across the outcome and the diagnostics"); diagnostics carries only the schema-aware findings layered on a structurally-valid parse. Schema- existence therefore runs only on a Match.
  • MatchedKind::Ident gained an IdentSource field so the post-walk schema check can tell a table reference from a column reference from an invented name without hard-coding role strings.
  • The debounce uses tokio::time::timeout per event-loop iteration, not tokio::select! — it keeps the existing ~100-line event-handler body un-reindented. recv is time-boxed only while a recompute is owed; an idle session still blocks plainly. Any event resets the window (not only keystrokes) — a slight over-reset, harmless in practice.
  • WARNING spans are per-literal. Each Operand AST node carries the byte span of the terminal it was built from, so an expression WARNING is anchored to exactly the offending literal. (The initial ship used a coarse whole-WHERE-clause span; made precise in the Follow-up section below.)
  • Highlight & hint wiring — complete. §2's "highlighting and the hint panel read the individual diagnostics" is wired — see the Follow-up section below. (The initial ship had the indicator and the model only; the build order did not enumerate the wiring as a step.)
  • Debounce decision logic is unit-tested — it is the IndicatorDebounce state machine in runtime (Follow-up section). The tokio::time::timeout interaction itself stays integration-level. The pure verdict (input_verdict / input_validity_verdict) and the indicator rendering were covered from the initial ship; input_verdict has a parse-outcome / schema-existence / expression-warning / existing-cases-sweep test set.

Follow-up — highlight & hint wiring, precise spans (2026-05-19)

The As-built notes above left three items open; this pass closes them. §2's design — "the indicator is the summary; highlighting and the hint panel read the individual diagnostics for where and why" — is now fully realised.

  • Operand carries a source span. Each WHERE-expression operand records the byte range of the terminal it was built from. Operand's PartialEq is hand-written to ignore the span — it is editor metadata — so Command equality, and the large Expr test corpus, stay whitespace- and position-independent. Expression WARNINGs are now anchored to exactly the offending literal, not the whole clause.
  • walker::input_diagnostics is the shared entry point both UI layers read — the sibling of input_verdict that returns the diagnostic set rather than its severity summary.
  • Highlight overlay. render_input_runs overlays the schema-aware diagnostics on the input field: an unknown table / column ERROR in the error colour, an expression WARNING in theme.warning. The overlay is global — every flagged token is coloured wherever it sits, not only under the cursor, which is precisely the problem §Context describes. The pre-existing cursor-local invalid-identifier overlay is kept (it covers in-progress identifiers, which produce no diagnostics); the two are additive and idempotent. overlay_span recolours a token's whole byte range (the older overlay_error hit only a single byte).
  • Hint panel. ambient_hint surfaces a diagnostic's message. input_diagnostics is non-empty only for a command that structurally parses — so a non-empty result means "complete and submittable, but wrong or dubious". It is checked early, ahead of slot hints and completions, so a flawed-but-parsing command no longer gets the misleading "Submit with Enter" prose. The diagnostic under the cursor wins (the panel explains where the user is looking); otherwise the most severe.
  • Debounce. The indicator-debounce decision logic moved out of the event loop into the IndicatorDebounce state machine, unit-tested for the keystroke / settle cycle.

The one piece still integration-level: a PTY-tier test of the actual tokio debounce timing (as distinct from the now-tested decision logic) — consistent with the four-tier strategy (ADR-0008), where Tier 4 is wired only for the critical flows.

Amendment 1 — LIKE on a numeric column (2026-05-19)

§1 defined the WARNING set as exactly a type-mismatched comparison and = NULL. A third trigger is added:

  • LIKE against a numeric column. LIKE is a text-pattern match — % / _ wildcards over characters. Applied to a column of a numeric type (int, real, decimal, serial) it still runs — the engine matches the pattern against the value's text form — but is almost never what the user wants; they most likely mean a comparison or BETWEEN. It is a WARNING, consistent with the advisory posture (§5): the command still submits. The negation is irrelevant — NOT LIKE on a numeric column is just as dubious.

Scope is deliberately narrow — only numeric target columns are flagged:

  • bool is integer-backed (0/1) but excluded; the handoff item this implements named numeric columns specifically.
  • The text-backed types (text, shortid, date, datetime) are not flagged: LIKE 'A%' on text is its intended use, and a prefix match on an ISO date / datetime string is genuinely useful.
  • blob is left unflagged.

Widening to those types is a future model extension if a need appears.

This is exactly the kind of "we can tell, before it runs, that this is dubious" case §6 anticipated: the trigger plugs into the existing model rather than being a one-off check. It lives alongside the others in walker::predicate_warnings (the Predicate::Like arm, via like_numeric_warning); the target column operand's span drives the highlight; the message key is diagnostic.like_numeric.

See also

  • ADR-0003 — input modes; the input field and its mode label.
  • ADR-0019 — the friendly-message catalog the diagnostic message keys resolve through.
  • ADR-0022 — ambient typing assistance: the immediate highlighting and hint layers this indicator summarises.
  • ADR-0026 — complex WHERE expressions; the type-mismatch and = NULL WARNING diagnostics.