Files
improvise/site/docs/test-evidence/protocol.html
T
Edward Langley 787e354b9b doc-site: name context/code/pr-review.md as an omission
The `build` job fails on main and on every branch merged from it:

  doc-site: context/code/pr-review.md is neither published nor a named omission

NOT a merge loss -- scripts/build-doc-site.mjs in this branch is
byte-identical to main's, and neither lists the file. PR #96 added
context/code/pr-review.md without a manifest line, which is exactly the
gate improvise-res9 added: a doc added without a line publishes nothing
and says nothing, so the generator refuses.

Omitted rather than published, and the line is cloud-agents.md's rather
than tooling.md's. tooling.md explains checkers a reader of the code will
meet; pr-review.md is about THIS REPOSITORY'S PROCESS -- where a Codex
review appears, how to tell it from our own agents' comments, why absence
is not approval. Nobody visiting a website is about to read a PR thread.

Both homes updated, because an omission is restated beside the manifest
AND in doc-site.md section 2, and the prose there counted them ("Neither
is referenced...") so it went stale at three.

This strays into scripts/, which is Wave N's ground. Flagged in the
hand-back; it is a one-line data entry either way if the orchestrator
would rather publish it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 11:12:00 -07:00

191 lines
60 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>improvise-protocol — improvise docs</title>
<link rel="stylesheet" href="../style.css" />
</head>
<body>
<div class="shell">
<nav class="side"><a class="brand" href="../index.html">improvise docs</a><input type="checkbox" id="navtoggle" class="navtoggle" /><label class="navsummary" for="navtoggle">Pages</label><div class="navlinks"><ul><li><a href="../../index.html">← Project home</a></li><li><a href="../../app/">Browser demo</a></li><li><a href="https://github.com/fiddlerwoaroof/improvise">Source on GitHub</a></li></ul><h3>Start here</h3><ul><li><a href="../design-notes.html">Design notes</a></li><li><a href="../design-principles.html">Design principles</a></li><li><a href="../repo-map.html">Repository map</a></li></ul><h3>Decision records</h3><ul><li><a href="../adr/001-concurrent-edits.html">ADR 001 — Concurrent edits</a></li><li><a href="../adr/002-naming-a-cell.html">ADR 002 — Naming a cell</a></li><li><a href="../adr/003-live-backed-models.html">ADR 003 — Live-backed models</a></li></ul><h3>The arguments, by area</h3><ul><li><a href="../code/core.html">improvise-core</a></li><li><a href="../code/formula-and-io.html">improvise-formula and improvise-io</a></li><li><a href="../code/protocol.html">improvise-protocol</a></li><li><a href="../code/command.html">improvise-command</a></li><li><a href="../code/clients.html">The clients</a></li><li><a href="../code/tui.html">The TUI</a></li><li><a href="../code/gp.html">gp-tree and improvise-gp</a></li><li><a href="../code/kb-tool.html">The kb tool</a></li><li><a href="../code/tooling.html">The repository's own checkers</a></li></ul><h3>Test evidence</h3><ul><li><a href="../test-evidence/core.html">improvise-core</a></li><li><a href="../test-evidence/formula-and-io.html">improvise-formula and improvise-io</a></li><li><a href="../test-evidence/protocol.html" class="here">improvise-protocol</a></li><li><a href="../test-evidence/command.html">improvise-command</a></li><li><a href="../test-evidence/clients.html">The clients</a></li><li><a href="../test-evidence/tui.html">The TUI</a></li><li><a href="../test-evidence/gp.html">gp-tree and improvise-gp</a></li><li><a href="../test-evidence/tooling.html">The repository's own gates</a></li></ul><h3>About this site</h3><ul><li><a href="../doc-site.html">About this site</a></li></ul><h3>Reference</h3><ul><li><a href="../tickets.html">Ticket index</a></li></ul></div></nav>
<main>
<p class="src">Source: <a href="https://github.com/fiddlerwoaroof/improvise/blob/main/context/test-evidence/protocol.md">context/test-evidence/protocol.md</a></p><details class="toc" open><summary>On this page</summary><ul><li class="l1"><a href="#test-evidence-improvise-protocol">Test evidence — improvise-protocol</a></li><li class="l2"><a href="#src-render-rs-the-fold">src/render.rs — the fold</a></li><li class="l3"><a href="#cell-updates-outside-the-cache-are-dropped">cell_updates_outside_the_cache_are_dropped</a></li><li class="l3"><a href="#a-cell-update-that-overtakes-its-labels-is-lost">a_cell_update_that_overtakes_its_labels_is_lost</a></li><li class="l3"><a href="#a-compacted-seed-folds-to-the-same-cache-as-the-whole-history">a_compacted_seed_folds_to_the_same_cache_as_the_whole_history</a></li><li class="l3"><a href="#a-snapshot-seeds-every-widget-from-its-compacted-state">a_snapshot_seeds_every_widget_from_its_compacted_state</a></li><li class="l3"><a href="#a-totals-update-that-overtakes-its-columns-is-cut-to-fit">a_totals_update_that_overtakes_its_columns_is_cut_to_fit</a></li><li class="l3"><a href="#an-empty-totals-strip-is-not-padded-into-blanks">an_empty_totals_strip_is_not_padded_into_blanks</a></li><li class="l3"><a href="#a-recategorized-axis-blanks-the-totals-too">a_recategorized_axis_blanks_the_totals_too</a></li><li class="l3"><a href="#a-relabelled-row-is-blanked-and-the-frame-survives">a_relabelled_row_is_blanked_and_the_frame_survives</a></li><li class="l3"><a href="#the-page-bar-outlives-a-reshape">the_page_bar_outlives_a_reshape</a></li><li class="l3"><a href="#the-first-rendered-entry-always-shows-its-level">the_first_rendered_entry_always_shows_its_level</a></li><li class="l3"><a href="#a-repeated-prefix-below-the-first-rendered-entry-is-suppressed">a_repeated_prefix_below_the_first_rendered_entry_is_suppressed</a></li><li class="l3"><a href="#a-data-index-skips-group-headers-and-still-names-its-group">a_data_index_skips_group_headers_and_still_names_its_group</a></li><li class="l3"><a href="#shared-ancestry-widens-as-the-level-rises">shared_ancestry_widens_as_the_level_rises</a></li><li class="l3"><a href="#body-from-starts-at-the-item-not-at-the-header-above-it">body_from_starts_at_the_item_not_at_the_header_above_it</a></li><li class="l3"><a href="#header-widths-are-per-level-and-measured-in-display-cells">header_widths_are_per_level_and_measured_in_display_cells</a></li><li class="l3"><a href="#a-column-without-its-width-yet-is-measured-in-display-cells">a_column_without_its_width_yet_is_measured_in_display_cells</a></li><li class="l3"><a href="#an-axis-with-no-categories-still-has-one-level">an_axis_with_no_categories_still_has_one_level</a></li><li class="l3"><a href="#a-relabelled-column-does-not-keep-the-old-columns-value">a_relabelled_column_does_not_keep_the_old_columns_value</a></li><li class="l3"><a href="#a-label-change-blanks-only-the-positions-it-reframes">a_label_change_blanks_only_the_positions_it_reframes</a></li><li class="l3"><a href="#renaming-a-row-category-invalidates-every-cell-under-it">renaming_a_row_category_invalidates_every_cell_under_it</a></li><li class="l3"><a href="#renaming-a-column-category-invalidates-every-cell-under-it">renaming_a_column_category_invalidates_every_cell_under_it</a></li><li class="l3"><a href="#an-unchanged-category-leaves-the-cells-that-still-fit">an_unchanged_category_leaves_the_cells_that_still_fit</a></li><li class="l3"><a href="#a-relabelled-column-drops-its-total">a_relabelled_column_drops_its_total</a></li><li class="l3"><a href="#a-relabelled-row-leaves-the-totals-alone">a_relabelled_row_leaves_the_totals_alone</a></li><li class="l3"><a href="#col-widths-always-span-the-column-labels">col_widths_always_span_the_column_labels</a></li><li class="l3"><a href="#a-col-widths-update-is-fitted-to-the-labels-it-is-framed-by">a_col_widths_update_is_fitted_to_the_labels_it_is_framed_by</a></li><li class="l3"><a href="#item-updates-touch-only-the-named-category">item_updates_touch_only_the_named_category</a></li><li class="l3"><a href="#an-axes-update-moves-several-categories-at-once">an_axes_update_moves_several_categories_at_once</a></li><li class="l3"><a href="#every-projection-round-trips-through-json">every_projection_round_trips_through_json</a></li><li class="l3"><a href="#a-populated-snapshot-round-trips-through-json">a_populated_snapshot_round_trips_through_json</a></li><li class="l2"><a href="#src-basis-rs">src/basis.rs</a></li><li class="l3"><a href="#a-client-that-has-folded-nothing-cannot-claim-a-basis">a_client_that_has_folded_nothing_cannot_claim_a_basis</a></li><li class="l3"><a href="#an-unknown-record-is-omitted-rather-than-guessed">an_unknown_record_is_omitted_rather_than_guessed</a></li><li class="l3"><a href="#a-claim-carries-the-counter-for-each-record-it-names">a_claim_carries_the_counter_for_each_record_it_names</a></li><li class="l3"><a href="#the-published-record-list-is-stable-under-traversal-order">the_published_record_list_is_stable_under_traversal_order</a></li><li class="l2"><a href="#src-frame-rs">src/frame.rs</a></li><li class="l3"><a href="#the-retired-spellings-do-not-parse">the_retired_spellings_do_not_parse</a></li><li class="l2"><a href="#src-reduce-rs">src/reduce.rs</a></li><li class="l3"><a href="#a-model-command-leaves-the-view-slice-untouched">a_model_command_leaves_the_view_slice_untouched</a></li><li class="l3"><a href="#folding-the-same-stream-twice-gives-the-same-state">folding_the_same_stream_twice_gives_the_same_state</a></li><li class="l3"><a href="#every-command-round-trips-through-json">every_command_round_trips_through_json</a></li><li class="l3"><a href="#cursor-commands-land-in-the-active-views-entry">cursor_commands_land_in_the_active_views_entry</a></li><li class="l3"><a href="#switching-views-restores-each-cursor">switching_views_restores_each_cursor</a></li><li class="l3"><a href="#help-pages-stop-at-both-ends-and-set-clamps">help_pages_stop_at_both_ends_and_set_clamps</a></li><li class="l2"><a href="#src-lib-rs">src/lib.rs</a></li><li class="l3"><a href="#the-wire-types-need-nothing-but-core-and-serde">the_wire_types_need_nothing_but_core_and_serde</a></li><li class="l2"><a href="#src-viewmodel-rs">src/viewmodel.rs</a></li><li class="l3"><a href="#expanding-an-absent-category-is-harmless">expanding_an_absent_category_is_harmless</a></li><li class="l3"><a href="#only-quit-advertises-nothing">only_quit_advertises_nothing</a></li><li class="l3"><a href="#every-advertised-hint-names-a-binding-that-exists">every_advertised_hint_names_a_binding_that_exists</a></li><li class="l3"><a href="#the-retired-invalidated-spelling-does-not-parse">the_retired_invalidated_spelling_does_not_parse</a></li><li class="l3"><a href="#boundary-tests-the-app-mode-cycle-reaches-every-variant-serde-declares">boundary_tests::the_app_mode_cycle_reaches_every_variant_serde_declares</a></li><li class="l3"><a href="#basis-tests-the-rejection-cycle-reaches-every-variant-serde-declares">basis::tests::the_rejection_cycle_reaches_every_variant_serde_declares</a></li><li class="l3"><a href="#variants-tests-a-type-that-is-not-an-externally-tagged-enum-is-refused">variants::tests::a_type_that_is_not_an_externally_tagged_enum_is_refused</a></li><li class="l3"><a href="#variants-tests-a-message-with-no-expected-clause-is-refused">variants::tests::a_message_with_no_expected_clause_is_refused</a></li><li class="l3"><a href="#variants-tests-a-message-that-lists-something-other-than-variants-is-refused">variants::tests::a_message_that_lists_something_other_than_variants_is_refused</a></li><li class="l3"><a href="#variants-tests-a-list-that-collapses-to-one-name-is-refused">variants::tests::a_list_that_collapses_to_one_name_is_refused</a></li><li class="l3"><a href="#variants-tests-a-well-formed-message-yields-its-names">variants::tests::a_well_formed_message_yields_its_names</a></li><li class="l3"><a href="#render-tests-a-frame-that-lost-its-origin-does-not-claim-to-lead-the-axis">render::tests::a_frame_that_lost_its_origin_does_not_claim_to_lead_the_axis</a></li><li class="l3"><a href="#render-tests-two-runs-under-two-headers-of-one-name-are-two-runs">render::tests::two_runs_under_two_headers_of_one_name_are_two_runs</a></li><li class="l3"><a href="#render-tests-the-two-headers-are-equal-as-values-so-comparing-cat-and-name-cannot-split-them">render::tests::the_two_headers_are_equal_as_values_so_comparing_cat_and_name_cannot_split_them</a></li><li class="l3"><a href="#render-tests-a-client-never-holds-widths-of-a-different-length-from-its-labels">render::tests::a_client_never_holds_widths_of_a_different_length_from_its_labels</a></li><li class="l3"><a href="#a-chord-is-recognised-by-its-modifiers-and-not-by-its-label">a_chord_is_recognised_by_its_modifiers_and_not_by_its_label</a></li></ul></details><h1 id="test-evidence-improvise-protocol">Test evidence — <code>improvise-protocol</code></h1>
<p>What each test&#39;s bug was, what fault it can see, and why it can fail. Keyed by exact test name (design-principles §7). A renamed test orphans its entry; <code>python3 scripts/check-comment-runs.py --check-evidence</code> catches that.</p>
<p>Tests whose name already states the whole property are not listed here. An entry earns its place by carrying something the assertion cannot: the bug, the injected fault, or the reason a <em>negative</em> arm exists.</p>
<hr />
<h2 id="src-render-rs-the-fold"><a class="anchor" href="#src-render-rs-the-fold">#</a><code>src/render.rs</code> — the fold</h2>
<h3 id="cell-updates-outside-the-cache-are-dropped"><a class="anchor" href="#cell-updates-outside-the-cache-are-dropped">#</a>cell_updates_outside_the_cache_are_dropped</h3>
<p>A projection naming a cell outside the current viewport must be <strong>dropped, not panic</strong>: frames and cell updates race on the wire, and a client that crashes on a stale index is a client that crashes. This is the totality half of the <code>WidgetCache</code> contract, stated as an example; the ordering half is <code>a_cell_update_that_overtakes_its_labels_is_lost</code>.</p>
<h3 id="a-cell-update-that-overtakes-its-labels-is-lost"><a class="anchor" href="#a-cell-update-that-overtakes-its-labels-is-lost">#</a>a_cell_update_that_overtakes_its_labels_is_lost</h3>
<p>The fold is <strong>not commutative</strong>, and a client must not be written as though it were. The frames frame the cells, so a <code>Cells</code> update that overtakes the <code>ColFrame</code>/<code>RowFrame</code> naming its positions is dropped. Reordering the identical three updates gives a different cache, and the <code>assert_ne!</code> is the load-bearing line — without it, a fold where order did not matter would pass.</p>
<h3 id="a-compacted-seed-folds-to-the-same-cache-as-the-whole-history"><a class="anchor" href="#a-compacted-seed-folds-to-the-same-cache-as-the-whole-history">#</a>a_compacted_seed_folds_to_the_same_cache_as_the_whole_history</h3>
<p>Bootstrap is not a second code path, but a server does not replay history to a late client either — it sends the state it holds <em>now</em>. So the property is <strong>compaction</strong>: folding the seed lands exactly where folding the whole history landed, including the history&#39;s relabels and overwrites.</p>
<p>The history is built to be worth compacting — overwritten, relabelled, populated and grown, with the extent moving twice so only the last survives — and <code>seed.len() &lt; history.len()</code> is asserted, because a &quot;seed&quot; that is not smaller than the history is not a compaction and the equality would be vacuous.</p>
<p>Note the general rule this is an instance of: asserting <code>fold(prefix) + fold(rest) == fold(whole)</code> is <strong>vacuous</strong>, being the definition of a left fold (<code>doctrine(fold_tests_must_test_order_not_the_axiom)</code>).</p>
<h3 id="a-snapshot-seeds-every-widget-from-its-compacted-state"><a class="anchor" href="#a-snapshot-seeds-every-widget-from-its-compacted-state">#</a>a_snapshot_seeds_every_widget_from_its_compacted_state</h3>
<p>The same property across the envelope, with two subscribers each folding only what is addressed to it — the dispatch a client does for real.</p>
<h3 id="a-totals-update-that-overtakes-its-columns-is-cut-to-fit"><a class="anchor" href="#a-totals-update-that-overtakes-its-columns-is-cut-to-fit">#</a>a_totals_update_that_overtakes_its_columns_is_cut_to_fit</h3>
<p>The totals strip is framed by the columns on the same terms the cells are: a strip that overtakes the <code>ColFrame</code> naming its positions is cut down, because a renderer zips it with the columns and a length mismatch indexes off the end.</p>
<h3 id="an-empty-totals-strip-is-not-padded-into-blanks"><a class="anchor" href="#an-empty-totals-strip-is-not-padded-into-blanks">#</a>an_empty_totals_strip_is_not_padded_into_blanks</h3>
<p>An <strong>empty</strong> strip says &quot;this grid has no totals&quot; — records mode, or an empty grid — and must not be padded into a row of blanks by the re-fit. Padding it would draw an empty total row under a records-mode grid that has none, which is the one thing the emptiness is for. This is the asymmetry that separates <code>totals</code> from <code>cells</code> and <code>col_widths</code>, which are always padded.</p>
<h3 id="a-recategorized-axis-blanks-the-totals-too"><a class="anchor" href="#a-recategorized-axis-blanks-the-totals-too">#</a>a_recategorized_axis_blanks_the_totals_too</h3>
<p>The totals are <strong>contents, not frame</strong>: a reframing blanks them for the same reason it blanks the cells. A stale sum under fresh cells is the one number on the screen a user would take at face value. The length assertion is the other half — the frame survives, the numbers do not.</p>
<p>Renamed by <a class="tid" href="../tickets.html#improvise-n89z" title="Three Projection mutations have no producer">improvise-n89z</a>, which deleted <code>GridUpdate::Invalidated</code>. The test used to reach the blanking through that arm; it now reaches it through a <code>cats</code> change, which is the thing that really produces it. Proved discriminating by pinning <code>recategorized</code> to <code>false</code>.</p>
<h3 id="a-relabelled-row-is-blanked-and-the-frame-survives"><a class="anchor" href="#a-relabelled-row-is-blanked-and-the-frame-survives">#</a>a_relabelled_row_is_blanked_and_the_frame_survives</h3>
<p>The other half of the same deletion. <code>invalidation_blanks_contents_but_keeps_ the_frame</code> asserted what the retired arm did; this asserts what <code>invalidate_relabelled</code> does, and it is <strong>strictly stronger</strong>, because the old arm blanked everything and so could not tell &quot;blanks what it reframes&quot; from &quot;blanks the lot&quot;. Row 1&#39;s label does not change and row 1 keeps its value. Proved discriminating by weakening the <code>stale</code> filter from <code>||</code> to <code>&amp;&amp;</code>.</p>
<h3 id="the-page-bar-outlives-a-reshape"><a class="anchor" href="#the-page-bar-outlives-a-reshape">#</a>the_page_bar_outlives_a_reshape</h3>
<p><code>page_coords</code> is framed by nothing: it names categories, not positions. So it survives a reshape that drops every row and column. Without this, &quot;re-fit everything on a frame update&quot; would look correct.</p>
<h3 id="the-first-rendered-entry-always-shows-its-level"><a class="anchor" href="#the-first-rendered-entry-always-shows-its-level">#</a>the_first_rendered_entry_always_shows_its_level</h3>
<p><strong>Bug (<a class="tid" href="../tickets.html#improvise-jjx" title="Scrolled grid suppresses first visible row-header label">improvise-jjx</a>)</strong>, moved here with the rule (<a class="tid" href="../tickets.html#improvise-anr9" title="Project the five grid-frame facts: unblocks H, z, gz and fixes Food/Alice at once">improvise-anr9</a>): header suppression compared against the entry at <code>idx - 1</code> even when that entry was scrolled off-screen, so scrolling <strong>blanked the group label at the top of the viewport</strong>. The first <em>rendered</em> entry always shows its full tuple.</p>
<p>The fixture is two-level so the level-0 prefix of index 1 genuinely matches index 0&#39;s — a single-level frame could not express the case.</p>
<h3 id="a-repeated-prefix-below-the-first-rendered-entry-is-suppressed"><a class="anchor" href="#a-repeated-prefix-below-the-first-rendered-entry-is-suppressed">#</a>a_repeated_prefix_below_the_first_rendered_entry_is_suppressed</h3>
<p>The negative arm of the above, and it has to stay: without it, &quot;always show every level&quot; would pass <code>the_first_rendered_entry_always_shows_its_level</code> and the multi-level header would repeat its outer label on every row.</p>
<h3 id="a-data-index-skips-group-headers-and-still-names-its-group"><a class="anchor" href="#a-data-index-skips-group-headers-and-still-names-its-group">#</a>a_data_index_skips_group_headers_and_still_names_its_group</h3>
<p>Group headers do not consume a data index, and a data index resolves to the group whose header precedes it — which is what makes a column group span drawable by a client that holds no <code>GridLayout</code>. The collapse state travelling with the group is the third assertion, and it is why <code>FrameGroup::collapsed</code> is on the wire rather than re-derived.</p>
<h3 id="shared-ancestry-widens-as-the-level-rises"><a class="anchor" href="#shared-ancestry-widens-as-the-level-rises">#</a>shared_ancestry_widens_as_the_level_rises</h3>
<p>The underline rule: at the leaf level only the selected column, at a higher level every column under the same ancestor. <strong>Both levels are checked</strong> because a version comparing whole tuples would pass the leaf arm and light only one column at the outer level, and a version comparing only the outermost label would pass the outer arm and light a sibling at the leaf.</p>
<h3 id="body-from-starts-at-the-item-not-at-the-header-above-it"><a class="anchor" href="#body-from-starts-at-the-item-not-at-the-header-above-it">#</a>body_from_starts_at_the_item_not_at_the_header_above_it</h3>
<p>A window opening mid-group must be able to <em>name</em> its group without painting that group&#39;s header as a row inside the window. The second assertion — <code>body_from(0)</code> is everything, headers included — is what stops the fix being &quot;drop all group headers&quot;.</p>
<h3 id="header-widths-are-per-level-and-measured-in-display-cells"><a class="anchor" href="#header-widths-are-per-level-and-measured-in-display-cells">#</a>header_widths_are_per_level_and_measured_in_display_cells</h3>
<p>The one width rule (<a class="tid" href="../tickets.html#improvise-1125" title="Row-header width is computed per frame in the widget">improvise-1125</a>): per level, the widest label plus a gap, clamped. Measured in <strong>display cells</strong>, so <code>日本</code> counts four and not two — a <code>chars().count()</code> would make level 1 two cells wide, clamp it to <code>MIN_ROW_HEADER_W</code>, and draw the header short. Both clamps are exercised (<code>&quot;ab&quot;</code> is under the floor, <code>&quot;x&quot; * 100</code> over the ceiling), because a rule whose clamps are never reached is a rule nobody has tested.</p>
<h3 id="a-column-without-its-width-yet-is-measured-in-display-cells"><a class="anchor" href="#a-column-without-its-width-yet-is-measured-in-display-cells">#</a>a_column_without_its_width_yet_is_measured_in_display_cells</h3>
<p><strong>Raised by Copilot on PR #23.</strong> The fallback width a column is drawn at between its <code>ColFrame</code> and its <code>ColWidths</code> must be in the same unit the authoritative widths are in. <code>chars().count()</code> would draw a wide label half as wide as it needs. The fixture is deliberately a two-scalar / four-cell string, which is the smallest input that separates the two counts.</p>
<h3 id="an-axis-with-no-categories-still-has-one-level"><a class="anchor" href="#an-axis-with-no-categories-still-has-one-level">#</a>an_axis_with_no_categories_still_has_one_level</h3>
<p>An axis carrying no category still draws one header line, and its item tuples are empty — reading a level off one must answer <code>&quot;&quot;</code> rather than panic. This is the shape a view with nothing on Row produces, so it is a live layout and not a degenerate fixture.</p>
<h3 id="a-relabelled-column-does-not-keep-the-old-columns-value"><a class="anchor" href="#a-relabelled-column-does-not-keep-the-old-columns-value">#</a>a_relabelled_column_does_not_keep_the_old_columns_value</h3>
<p>A column dropped from the <strong>middle</strong> used to slide the cells to its right one place left, because <code>fit_to_labels</code> truncates by position: <code>[A,B,C]</code> shrinking to <code>[A,C]</code> left the column now labelled <code>C</code> showing B&#39;s value. Nothing on the wire ever forced a producer to declare it first, so <code>apply</code> blanks what it reframes.</p>
<h3 id="a-label-change-blanks-only-the-positions-it-reframes"><a class="anchor" href="#a-label-change-blanks-only-the-positions-it-reframes">#</a>a_label_change_blanks_only_the_positions_it_reframes</h3>
<p>The same rule on the row axis, and the case a bare label shrink gets right by accident: only the <em>relabelled</em> positions are blanked, so an unchanged row keeps what the server already sent for it.</p>
<h3 id="renaming-a-row-category-invalidates-every-cell-under-it"><a class="anchor" href="#renaming-a-row-category-invalidates-every-cell-under-it">#</a>renaming_a_row_category_invalidates_every_cell_under_it</h3>
<p>Renaming the <strong>category</strong> re-identifies every cell on the axis, even where every label is byte-identical: a coordinate is <code>cats</code> zipped with the item tuple, so <code>Region = [North]</code> and <code>Department = [North]</code> are different cells wearing the same name.</p>
<p><strong>Frame-only, and that word is the test.</strong> No <code>Cells</code> and no <code>Totals</code> follow the frame update, because the producer&#39;s follow-up is exactly what would hide the bug — <code>apply</code> has to be right with nothing behind it, the same shape as <code>a_cell_update_that_overtakes_its_labels_is_lost</code>.</p>
<p>The <code>id</code> assertion is the sharp half and is separate from the text on purpose. A stale <code>CellId::Record</code> under a re-identified coordinate is an editable cell whose write addresses the record the <strong>old</strong> coordinate named — silent, invisible in any rendering, and the same family of defect as gotcha 18&#39;s drill snapshot.</p>
<p>The precondition assertion (the <em>items</em> are identical) is load-bearing: without it the test is just the ordinary relabelling case, and <code>cats</code> proves nothing.</p>
<h3 id="renaming-a-column-category-invalidates-every-cell-under-it"><a class="anchor" href="#renaming-a-column-category-invalidates-every-cell-under-it">#</a>renaming_a_column_category_invalidates_every_cell_under_it</h3>
<p>The column half of the same rule. Here the per-position arm already blanks each total as its column goes stale, so this pins the cells and the ids; the row arm above is the one that needed the totals handled separately.</p>
<h3 id="an-unchanged-category-leaves-the-cells-that-still-fit"><a class="anchor" href="#an-unchanged-category-leaves-the-cells-that-still-fit">#</a>an_unchanged_category_leaves_the_cells_that_still_fit</h3>
<p><strong>The negative arm, and it is load-bearing</strong>: without it, &quot;blank everything on every frame update&quot; would pass both renaming tests above, and the fold would resend the whole grid after a group collapse.</p>
<h3 id="a-relabelled-column-drops-its-total"><a class="anchor" href="#a-relabelled-column-drops-its-total">#</a>a_relabelled_column_drops_its_total</h3>
<p><strong><a class="tid" href="../tickets.html#improvise-hkt6" title="Three small totality gaps found in review">improvise-hkt6</a>.</strong> <code>invalidate_relabelled</code> blanked the cells under a renamed column and left its <strong>total</strong> standing, so a client that heard <code>ColFrame</code> without a <code>Totals</code> behind it drew the old column&#39;s sum under the new column&#39;s blanks.</p>
<p>Unreachable from <code>grid_updates</code>, which always sends the changed totals in the same batch — <strong>which is the point</strong>: the rule is that <code>apply</code> does not trust a producer to remember, and the only way to test it is to send the update the producer never sends alone.</p>
<p>Three columns and a rename in the <em>middle</em>, so &quot;blanked the right one&quot; is separable from &quot;blanked all of them&quot;.</p>
<h3 id="a-relabelled-row-leaves-the-totals-alone"><a class="anchor" href="#a-relabelled-row-leaves-the-totals-alone">#</a>a_relabelled_row_leaves_the_totals_alone</h3>
<p>The row half is the <strong>negative</strong> arm and has to stay: a total spans every row, so no single row frames it. Without this, &quot;blank the totals on any relabel&quot; would pass <code>a_relabelled_column_drops_its_total</code>.</p>
<h3 id="col-widths-always-span-the-column-labels"><a class="anchor" href="#col-widths-always-span-the-column-labels">#</a>col_widths_always_span_the_column_labels</h3>
<p><code>col_widths</code> is framed by the columns: a renderer zips the two, so a cache where they disagree in length is one that indexes off the end. <code>ColWidths</code> is its own update and may be a frame behind the frames.</p>
<h3 id="a-col-widths-update-is-fitted-to-the-labels-it-is-framed-by"><a class="anchor" href="#a-col-widths-update-is-fitted-to-the-labels-it-is-framed-by">#</a>a_col_widths_update_is_fitted_to_the_labels_it_is_framed_by</h3>
<p>The same invariant seen from the other arm. <code>ColWidths</code> used to be assigned verbatim, so a widths update that disagreed with the frame put the cache straight back into the state <code>col_widths_always_span_the_column_labels</code> exists to forbid — the frame arm was fitted and the widths arm was not.</p>
<p>A <strong>short</strong> update is the ordinary case: the server resized a column the client has not been told about yet. A <strong>long</strong> one means the widths overtook the <code>ColFrame</code> that frames them, and the fold drops what overtakes its frame — the same rule <code>Cells</code> is already held to. Both directions are asserted, because fitting only one of them is exactly the bug.</p>
<h3 id="item-updates-touch-only-the-named-category"><a class="anchor" href="#item-updates-touch-only-the-named-category">#</a>item_updates_touch_only_the_named_category</h3>
<p>Plus the dropped-update arm at the end: an <code>Items</code> update for a category the cache has never heard of is dropped, not inserted — the totality rule again, on a different widget.</p>
<h3 id="an-axes-update-moves-several-categories-at-once"><a class="anchor" href="#an-axes-update-moves-several-categories-at-once">#</a>an_axes_update_moves_several_categories_at_once</h3>
<p>A transpose moves two axes at once, which is why <code>Axes</code> takes a list rather than a single pair. The third assertion — membership is undisturbed — is what stops the fix being &quot;resend the whole category list&quot;.</p>
<h3 id="every-projection-round-trips-through-json"><a class="anchor" href="#every-projection-round-trips-through-json">#</a>every_projection_round_trips_through_json</h3>
<p>One <strong>populated</strong> value per <code>CellDisplay</code> shape: <code>numeric</code> decides the alignment and <code>error</code> the styling, so a round trip that only ever carries them at their default proves nothing about either (<code>doctrine(populate_before_roundtrip)</code>).</p>
<h3 id="a-populated-snapshot-round-trips-through-json"><a class="anchor" href="#a-populated-snapshot-round-trips-through-json">#</a>a_populated_snapshot_round_trips_through_json</h3>
<p>The <code>Snapshot</code> is the only thing on the wire that is not an event, and the only point at which <code>ViewState</code> is serialized (<a class="tid" href="../tickets.html#improvise-q08" title="improvise-ws-server binary (tokio + tungstenite session wrapper)">improvise-q08</a>) — so it is the one message whose round trip nothing else covers. Populated on <strong>both</strong> halves: a default <code>ViewState</code> beside an empty seed would round-trip through almost no code.</p>
<hr />
<h2 id="src-basis-rs"><a class="anchor" href="#src-basis-rs">#</a><code>src/basis.rs</code></h2>
<h3 id="a-client-that-has-folded-nothing-cannot-claim-a-basis"><a class="anchor" href="#a-client-that-has-folded-nothing-cannot-claim-a-basis">#</a>a_client_that_has_folded_nothing_cannot_claim_a_basis</h3>
<p>The floor. A client that has been told nothing cannot compose a basis, so it cannot compose a write. <code>Basis</code> has no <code>Default</code> and no public constructor, so <code>BasisCache::claim</code> is the <em>only</em> door — which is what this asserts is shut before the server has published anything.</p>
<h3 id="an-unknown-record-is-omitted-rather-than-guessed"><a class="anchor" href="#an-unknown-record-is-omitted-rather-than-guessed">#</a>an_unknown_record_is_omitted_rather_than_guessed</h3>
<p>A record the client has never been told about is <strong>left out</strong>, not invented. Defaulting it to zero would be a claim the client cannot support, and the server would read it as &quot;untouched&quot; — the exact forged-basis case <code>Rejection::RecordUnstated</code> exists to catch.</p>
<h3 id="a-claim-carries-the-counter-for-each-record-it-names"><a class="anchor" href="#a-claim-carries-the-counter-for-each-record-it-names">#</a>a_claim_carries_the_counter_for_each_record_it_names</h3>
<p>The second assertion is the one that earns its place: a claim naming one record must not carry another&#39;s counter. A <code>claim</code> that returned the whole cache would pass the first assertion and fail this.</p>
<h3 id="the-published-record-list-is-stable-under-traversal-order"><a class="anchor" href="#the-published-record-list-is-stable-under-traversal-order">#</a>the_published_record_list_is_stable_under_traversal_order</h3>
<p>The published list is deduplicated and ordered, so a client is not sent a fresh basis every time a row moves on screen. Two maps built in opposite insertion order must compare equal — which is the property the <code>BTreeMap</code> parameter buys and a <code>Vec</code> would not.</p>
<hr />
<h2 id="src-frame-rs"><a class="anchor" href="#src-frame-rs">#</a><code>src/frame.rs</code></h2>
<h3 id="the-retired-spellings-do-not-parse"><a class="anchor" href="#the-retired-spellings-do-not-parse">#</a>the_retired_spellings_do_not_parse</h3>
<p>The names the <strong>deleted</strong> envelopes used, which must not resurrect as serde aliases: an envelope accepting two spellings of one variant is two homes again, wearing one type.</p>
<p>Not a hypothetical rename — <code>Bootstrap</code> and <code>Command</code> are exactly what the worker pair (<code>improvise-wasm-client::wire</code>) spelled these two payloads before <a class="tid" href="../tickets.html#improvise-xks6" title="One home for the client/server frame envelopes">improvise-xks6</a>. <code>ClientFrame::Command</code> joined the list with <a class="tid" href="../tickets.html#improvise-bpq2" title="Client-side key resolution: what must the projections carry?">improvise-bpq2</a>, and it is the sharper of the two: the singleton spelling was this crate&#39;s own until that commit, so an alias would be a <em>revival</em> rather than a resurrection, and the payload shapes differ only by a pair of brackets — <code>{&quot;Command&quot;:{...}}</code> against <code>{&quot;Commands&quot;:[{...}]}</code> — which is exactly the difference a permissive alias would paper over.</p>
<p><strong>The injected-fault discipline is the part to keep</strong> (<code>doctrine(check_the_checker)</code>): the mutated input is asserted <strong>well-formed JSON first</strong>, and the failure is asserted to be about the <em>name</em> — serde&#39;s <code>unknown variant</code> — not merely that an error occurred. A rewrite that also breaks the JSON makes this pass against an enum that really does have the variant.</p>
<hr />
<h2 id="src-reduce-rs"><a class="anchor" href="#src-reduce-rs">#</a><code>src/reduce.rs</code></h2>
<h3 id="a-model-command-leaves-the-view-slice-untouched"><a class="anchor" href="#a-model-command-leaves-the-view-slice-untouched">#</a>a_model_command_leaves_the_view_slice_untouched</h3>
<p>A model command is the document&#39;s business, not this client&#39;s. Folding one into the view slice would be a client writing state it does not own — the document&#39;s answer comes back as projections. The cursor is set to a non-default value first, so &quot;the whole state is still default&quot; cannot stand in for &quot;the state did not move&quot;.</p>
<h3 id="folding-the-same-stream-twice-gives-the-same-state"><a class="anchor" href="#folding-the-same-stream-twice-gives-the-same-state">#</a>folding_the_same_stream_twice_gives_the_same_state</h3>
<p>Both ends fold the same commands, so the same stream must produce the same state — otherwise the server&#39;s mirror drifts from the client it is mirroring (<a class="tid" href="../tickets.html#improvise-m91" title="(Stretch) Command log + native undo/replay">improvise-m91</a>). A <code>Projection</code> is included in the stream, to pin that it passes through <code>reduce_view</code> untouched rather than being a case it handles.</p>
<h3 id="every-command-round-trips-through-json"><a class="anchor" href="#every-command-round-trips-through-json">#</a>every_command_round_trips_through_json</h3>
<p>One populated value per payload <strong>family</strong> (<code>doctrine(populate_before_roundtrip)</code>), not per variant: the exhaustive walk over the 26 <code>ModelCommand</code> variants is <code>every_model_command</code> in <code>improvise-command</code>, where the effect each one names can also be checked.</p>
<p>The <code>modelled()</code> helper composes its basis through a seeded <code>BasisCache</code> rather than building a <code>Basis</code> literal — not because this test is about the basis, but because <strong>that is the only constructor there is</strong>, and a test that needed a second one would be evidence the door had been left open.</p>
<h3 id="cursor-commands-land-in-the-active-views-entry"><a class="anchor" href="#cursor-commands-land-in-the-active-views-entry">#</a>cursor_commands_land_in_the_active_views_entry</h3>
<p>The second assertion is the point: the view we switched <em>away</em> from never gained an entry. An implementation writing a single cursor and copying it per view would pass the first assertion.</p>
<h3 id="switching-views-restores-each-cursor"><a class="anchor" href="#switching-views-restores-each-cursor">#</a>switching_views_restores_each_cursor</h3>
<p>The point of keying the cursor map: switching back restores where you were, and the client can do that <strong>without a <code>Workbook</code></strong>.</p>
<h3 id="help-pages-stop-at-both-ends-and-set-clamps"><a class="anchor" href="#help-pages-stop-at-both-ends-and-set-clamps">#</a>help_pages_stop_at_both_ends_and_set_clamps</h3>
<p>Both bounds and the clamp, because going back from page 0 is the one that underflows if written as <code>help_page - 1</code>, and <code>Set</code> out of range is the one that panics if written as an index.</p>
<p>It used to be named <code>..._wrap_in_both_directions_...</code> and asserted wrapping, which was a <strong>second derivation of the effects it came from and disagreed with them</strong>: <code>effect::HelpPageNext</code> clamps at the last page and <code>HelpPagePrev</code> saturates at zero, while this arm wrapped modulo <code>HELP_PAGE_COUNT</code>. Nothing compared the two, because no client had ever sent <code>Command::HelpPage</code> — the help overlay&#39;s <code>h</code>/<code>l</code> were still upstream keys. <a class="tid" href="../tickets.html#improvise-e24v" title="Second tranche of client-side key resolution: ~15 bindings needing no enrichment">improvise-e24v</a> made them a client&#39;s, and <code>walking_off_the_end_of_the_help_pages_agrees_with_the_host</code> failed on the first press past the end (client 0, host 4). The wire was changed to match the effect rather than the other way round: the effect is what ships in the TUI, and changing <em>that</em> is a product decision nobody asked for. Whether wrapping is wanted on both ends is filed separately.</p>
<hr />
<h2 id="src-lib-rs"><a class="anchor" href="#src-lib-rs">#</a><a href="https://github.com/fiddlerwoaroof/improvise/blob/main/src/lib.rs"><code>src/lib.rs</code></a></h2>
<h3 id="the-wire-types-need-nothing-but-core-and-serde"><a class="anchor" href="#the-wire-types-need-nothing-but-core-and-serde">#</a>the_wire_types_need_nothing_but_core_and_serde</h3>
<p><a class="tid" href="../tickets.html#improvise-cqi" title="Extract improvise-protocol crate (Command, ViewState, reduce_view)">improvise-cqi</a> AC 2: this crate crosses a process boundary, so it must not drag a terminal backend with it. A <code>cargo tree</code> check would be external; this is the part the <strong>compiler</strong> can hold — the types here resolve with only <code>improvise-core</code> and <code>serde</code> in scope, and adding a <code>ratatui</code> or <code>crossterm</code> dependency would have to be written into <a href="https://github.com/fiddlerwoaroof/improvise/blob/main/Cargo.toml"><code>Cargo.toml</code></a> deliberately rather than arriving through a <code>use</code>. The CI <code>wasm</code> job is the other half: a crate pulling in crossterm would stop building for wasm32.</p>
<hr />
<h2 id="src-viewmodel-rs"><a class="anchor" href="#src-viewmodel-rs">#</a><code>src/viewmodel.rs</code></h2>
<h3 id="expanding-an-absent-category-is-harmless"><a class="anchor" href="#expanding-an-absent-category-is-harmless">#</a>expanding_an_absent_category_is_harmless</h3>
<p>An expanded name the cache does not carry must not shift the flattening: <code>expanded_cats</code> is per-client and survives a document swap, so it <strong>routinely</strong> names categories that are gone. A flattening that indexed by expanded-name would go off the end here.</p>
<h3 id="only-quit-advertises-nothing"><a class="anchor" href="#only-quit-advertises-nothing">#</a>only_quit_advertises_nothing</h3>
<p><a class="tid" href="../tickets.html#improvise-2hi" title="Extract AppMode dispatch methods (mode_label, mode_style, mode_key)">improvise-2hi</a>, carried over from <code>only_quit_has_no_hint</code> when the hints moved out of <code>AppMode</code> (<a class="tid" href="../tickets.html#improvise-k099" title="AppMode::hint_text/label put terminal UI strings on a serde wire enum">improvise-k099</a>). The old <code>hint_text</code> had a <code>_ =&gt; &quot;&quot;</code> arm, so the modes without hints were invisible; <code>ExportPrompt</code> was blank too (<a class="tid" href="../tickets.html#improvise-1c9" title="ExportPrompt mode shows no status-bar hint">improvise-1c9</a>), and the status bar went empty at the one moment the user has to be told what to type. Only <code>Quit</code> may say nothing — it is never rendered.</p>
<p>What it asserts now is one step stronger, because <code>hint_line</code> is derived: a mode goes quiet not by someone forgetting a string but by every command it advertises becoming unbound, which <code>every_advertised_hint_names_a_binding_that_exists</code> catches first.</p>
<h3 id="every-advertised-hint-names-a-binding-that-exists"><a class="anchor" href="#every-advertised-hint-names-a-binding-that-exists">#</a>every_advertised_hint_names_a_binding_that_exists</h3>
<p>The replacement for <code>every_hint_key_resolves_in_its_mode</code> (<a class="tid" href="../tickets.html#improvise-2hi" title="Extract AppMode dispatch methods (mode_label, mode_style, mode_key)">improvise-2hi</a>), which is <strong>deleted</strong>, and the deletion is the point (<code>doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)</code>).</p>
<p>That test existed because <code>AppMode::hint_text</code> was a hand-written string promising what the keys do, with nothing tying it to the keymaps. It walked every <code>Key:verb</code> token in every hint through a 60-line tokenizer that had to know the hint dialect — named keys, <code>Ctrl-&lt;c&gt;</code>, <code>F/C/V</code> alternatives, runs like <code>hjkl</code>, the em-dash prose tail, CommandMode&#39;s ex-commands — and panicked on a token it could not classify, because a token it skipped was exactly the one that would go unchecked.</p>
<p>None of that is needed now. A hint names a <strong>command</strong>, and its keys are looked up in <code>Keymaps::default_keymaps()</code>, so &quot;the hint names a key that resolves&quot; is true by construction and cannot be otherwise. What remains checkable, and is what this test checks, is the other direction: a mode advertising a command that nothing binds. That was never checkable before.</p>
<p>Note what neither version reaches, and gotcha 14 stated for the old one: that the <em>caption</em> is accurate. <code>i:edit</code> where <code>i</code> enters records mode still passes. The caption is English beside a command name, and only a reader can judge it.</p>
<h3 id="the-retired-invalidated-spelling-does-not-parse"><a class="anchor" href="#the-retired-invalidated-spelling-does-not-parse">#</a>the_retired_invalidated_spelling_does_not_parse</h3>
<p><a class="tid" href="../tickets.html#improvise-n89z" title="Three Projection mutations have no producer">improvise-n89z</a> deleted <code>GridUpdate::Invalidated</code>, and the one way it can come back without anyone noticing is as a serde <strong>alias</strong> — a wire spelling that parses and that nothing produces, which is the state the deletion was for.</p>
<p>It keeps the discipline <code>the_retired_spellings_do_not_parse</code> earned in <code>frame.rs</code>: assert the mutated input is still <strong>well-formed</strong> first (a <code>ColWidths</code> projection of the same shape parses), and assert the <strong>error kind</strong> — serde&#39;s <code>unknown variant</code> — rather than merely that an error occurred, because a rewrite that also broke the JSON would make the check pass against an enum that really did have the variant.</p>
<p>Proved discriminating by re-adding the variant with an empty <code>apply</code> arm: the retired spelling parses again and the test fails on <code>expect_err</code>.</p>
<h3 id="boundary-tests-the-app-mode-cycle-reaches-every-variant-serde-declares"><a class="anchor" href="#boundary-tests-the-app-mode-cycle-reaches-every-variant-serde-declares">#</a>boundary_tests::the_app_mode_cycle_reaches_every_variant_serde_declares</h3>
<h3 id="basis-tests-the-rejection-cycle-reaches-every-variant-serde-declares"><a class="anchor" href="#basis-tests-the-rejection-cycle-reaches-every-variant-serde-declares">#</a>basis::tests::the_rejection_cycle_reaches_every_variant_serde_declares</h3>
<p><a class="tid" href="../tickets.html#improvise-5ubs" title="A cyclic-successor all() can silently omit a variant while its doc says it cannot">improvise-5ubs</a>. Both <code>all()</code> functions walk a cyclic <code>after()</code>, and both doc comments claimed a new variant could not be omitted. Answering <code>after()</code> for a new variant is compiler-forced; pointing an existing arm at it is not, so a variant nothing names is never walked. The reviewer of PR #78 measured it: <code>AppMode::all().len()</code> came back 17 against 18 variants, suite green.</p>
<p>The independent enumeration is serde&#39;s — the <code>Deserialize</code> derive builds the variant-name list from the enum definition and publishes it in its <code>unknown variant ... expected one of ...</code> message. <code>variants.rs</code> reads it back and diffs it against the names the cycle reached, each name taken off the variant&#39;s own serialization rather than off <code>Debug</code>.</p>
<p>Fault injected 2026-09-21: a <code>Rejection::Probe</code> variant added to the enum, with an <code>after()</code> arm returning <code>DocumentReplaced</code> and <strong>nothing pointing at it</strong> — precisely the shape the reviewer probed. <code>the_rejection_cycle_reaches_every_variant_serde_declares</code> fails naming <code>[&quot;Probe&quot;]</code>; the other 120 tests in <code>improvise-protocol</code>&#39;s lib pass, including <code>the_rejection_cycle</code> half of every basis test. (The workspace does not compile with the probe, because <code>improvise-wasm-client/src/socket.rs</code> matches <code>Rejection</code> exhaustively — which is a <em>different</em> guard and does not catch the omission, since the omitted variant there is one it was forced to name.)</p>
<h3 id="variants-tests-a-type-that-is-not-an-externally-tagged-enum-is-refused"><a class="anchor" href="#variants-tests-a-type-that-is-not-an-externally-tagged-enum-is-refused">#</a>variants::tests::a_type_that_is_not_an_externally_tagged_enum_is_refused</h3>
<h3 id="variants-tests-a-message-with-no-expected-clause-is-refused"><a class="anchor" href="#variants-tests-a-message-with-no-expected-clause-is-refused">#</a>variants::tests::a_message_with_no_expected_clause_is_refused</h3>
<h3 id="variants-tests-a-message-that-lists-something-other-than-variants-is-refused"><a class="anchor" href="#variants-tests-a-message-that-lists-something-other-than-variants-is-refused">#</a>variants::tests::a_message_that_lists_something_other_than_variants_is_refused</h3>
<h3 id="variants-tests-a-list-that-collapses-to-one-name-is-refused"><a class="anchor" href="#variants-tests-a-list-that-collapses-to-one-name-is-refused">#</a>variants::tests::a_list_that_collapses_to_one_name_is_refused</h3>
<h3 id="variants-tests-a-well-formed-message-yields-its-names"><a class="anchor" href="#variants-tests-a-well-formed-message-yields-its-names">#</a>variants::tests::a_well_formed_message_yields_its_names</h3>
<p>The checker&#39;s own checker. A parse of serde&#39;s message that silently failed would return an empty name list, and an empty list is a subset of everything — every cycle would pass and the guard would be worse than absent.</p>
<p><strong>The first version named one thing and exercised another</strong>, found by the PR #92 review. It pointed <code>declared</code> at <code>Basis</code>, a <strong>struct</strong>, so serde answers <code>invalid type … expected struct Basis</code> — which reaches only the <code>split_once → None</code> branch. The <code>starts_with(&quot;unknown variant&quot;)</code> and <code>names.len() &gt; 1</code> refusals were exercised by nothing, and all three panics shared the literal <code>&quot;serde no longer says&quot;</code>, so <code>expected = …</code> could not discriminate which had fired. <strong>A <code>should_panic</code> whose message matches several panics is a check that cannot say what it caught.</strong></p>
<p>The parse is now <code>parse_expected(&amp;str)</code>, a seam fed hand-written mutated messages, each refusal carrying its own wording:</p>
<ul>
<li>no <code>expected one of</code> clause → <code>serde stopped listing the variants</code></li>
<li>an <code>unknown field</code> list rather than a variant list → <code>serde stopped saying \</code>unknown variant\``</li>
<li>a list that collapses to one name → <code>the variant list did not parse</code></li>
</ul>
<p>plus a happy-path case, so the three refusals are not the only evidence the parser runs at all. <code>a_type_that_is_not_an_externally_tagged_enum_is_refused</code> keeps the end-to-end shape through a real <code>Deserialize</code>.</p>
<h3 id="render-tests-a-frame-that-lost-its-origin-does-not-claim-to-lead-the-axis"><a class="anchor" href="#render-tests-a-frame-that-lost-its-origin-does-not-claim-to-lead-the-axis">#</a>render::tests::a_frame_that_lost_its_origin_does_not_claim_to_lead_the_axis</h3>
<p><a class="tid" href="../tickets.html#improvise-1wl0" title="AxisFrame::first is serde(default), so a frame with no origin claims it leads the axis">improvise-1wl0</a>, found by the adversarial review of PR #77. <code>AxisFrame::first</code> carried <code>#[serde(default)]</code>, so a frame whose <code>first</code> was dropped in transit deserialized as <code>0</code> — which is not an absence but the claim that the frame <em>leads the axis</em>, and exactly what <code>body_from</code> acts on: <code>match self.first + idx == 0</code>. A scrolled browser would then draw the carried group header above its first row, at every scroll position, silently: the mirror image of the <a class="tid" href="../tickets.html#improvise-0o6x" title="A group header first on its axis is never drawn, so a leading collapsed group has no way back">improvise-0o6x</a> bug the field was added to fix.</p>
<p>The test serializes a frame built for a scrolled window (<code>first: 3</code>), removes the key, and asserts the refusal. Three things make it discriminating rather than a check that something went wrong:</p>
<ul>
<li>It asserts the key <strong>was there</strong> before removing it, so a rename of the field turns this into a failure rather than a vacuous pass.</li>
<li>It asserts the mutated value is <strong>still a well-formed AxisFrame minus one field</strong> (<code>cats</code> and <code>entries</code> present), so the refusal cannot be about broken JSON — the check-the-checker hole gotcha 42 records from <code>the_retired_spellings_do_not_parse</code>.</li>
<li>It asserts the <strong>error kind</strong> is <code>missing field</code>, not merely that an error occurred. A <code>first</code> that had been retyped would fail differently and this would notice.</li>
</ul>
<p>Run against the pre-fix tree 2026-09-21: it fails at the <code>expect_err</code>, printing <code>AxisFrame { .., first: 0, .. }</code> — the forged claim itself. The fix is the removal of one attribute; the whole workspace stays green (1464), which is also the evidence that nothing was relying on the permissive parse.</p>
<h3 id="render-tests-two-runs-under-two-headers-of-one-name-are-two-runs"><a class="anchor" href="#render-tests-two-runs-under-two-headers-of-one-name-are-two-runs">#</a>render::tests::two_runs_under_two_headers_of_one_name_are_two_runs</h3>
<h3 id="render-tests-the-two-headers-are-equal-as-values-so-comparing-cat-and-name-cannot-split-them"><a class="anchor" href="#render-tests-the-two-headers-are-equal-as-values-so-comparing-cat-and-name-cannot-split-them">#</a>render::tests::the_two_headers_are_equal_as_values_so_comparing_cat_and_name_cannot_split_them</h3>
<p><a class="tid" href="../tickets.html#improvise-h0iv" title="group_runs coalesces by name alone, so two runs under different headers merge">improvise-h0iv</a>. <code>group_runs</code> coalesced adjacent positions by group <strong>name</strong>, so two runs under two different headers sharing a name read as one and both front ends drew one span across the boundary.</p>
<p>The first test is the demonstration, at the level the rule lives: a frame with <code>[Group(Month/Q1), North/Jul, North/Aug, Group(Month/Q1), South/Jul, South/Aug]</code> and nothing ungrouped between the runs. Before the fix it returns <code>[(Q1, 4)]</code>; after, <code>[(Q1, 2), (Q1, 2)]</code>. It asserts the frame really holds <strong>two</strong> <code>FrameEntry::Group</code> entries first, so a fixture that lost one would fail at the precondition rather than pass with a single run.</p>
<p>The second test exists because the ticket proposed the wrong fix and a reader will propose it again. h0iv says <code>FrameGroup</code> carries <code>cat</code> and &quot;comparing the pair would fix it&quot; — but its own reachability case is a single category nested under an outer one, so both headers are <code>(Month, Q1)</code>, equal as values. The test asserts that equality directly. What actually separates them is that they are two distinct entries in <code>self.entries</code>, which <code>std::ptr::eq</code> on the borrowed header states exactly.</p>
<p>Fault injected 2026-09-21: restoring <code>prev.map(|p| p.name.as_str()) == ...</code> fails the first test with <code>left: [(Some(&quot;Q1&quot;), 4)]</code>, and fails <code>two_abutting_runs_of_one_group_name_are_two_spans_at_both_ends</code> end to end. The second test passes under both rules, deliberately — it is a statement about the data, not about the comparison.</p>
<h3 id="render-tests-a-client-never-holds-widths-of-a-different-length-from-its-labels"><a class="anchor" href="#render-tests-a-client-never-holds-widths-of-a-different-length-from-its-labels">#</a>render::tests::a_client_never_holds_widths_of_a_different_length_from_its_labels</h3>
<p><a class="tid" href="../tickets.html#improvise-44ad" title="ColWidths is redundant under the label fold, and the test that would change is gotcha 33(c)&#39;s evidence">improvise-44ad</a>&#39;s replacement test, landed while the move it was written for was <strong>declined</strong>. A renderer zips <code>col_widths</code> with the column labels, so a length mismatch indexes off the end (gotcha 33(c)). The existing guards assert the re-fit on individual arms; this one asserts the invariant across a <strong>fold</strong>, at every step, over a stream that widens the frame before its widths arrive, lands a cell, catches the widths up, narrows the frame, lands totals, and catches up again.</p>
<p>It asserts the first update really widens past the widths held, so a fixture that stopped exercising the gap would fail at the precondition rather than pass.</p>
<p>Fault injected 2026-09-21: deleting the width re-fit from <code>fit_to_labels</code> (<code>self.col_widths.truncate(cols); if self.col_widths.len() &lt; cols {</code> replaced by <code>if false {</code>) fails this at <strong>step 0</strong> — 3 widths for 5 columns.</p>
<p><strong>What that injection also established, and it corrects <a class="tid" href="../tickets.html#improvise-44ad" title="ColWidths is redundant under the label fold, and the test that would change is gotcha 33(c)&#39;s evidence">improvise-44ad</a>.</strong> Only four tests in the whole workspace fail: this one and three that already existed (<code>a_col_widths_update_is_fitted_to_the_labels_it_is_framed_by</code>, <code>a_column_without_its_width_yet_is_measured_in_display_cells</code>, <code>col_widths_always_span_the_column_labels</code>). The ticket&#39;s premise was that <code>a_client_on_a_socket_is_seeded_and_gets_its_edit_back</code> is gotcha 33(c)&#39;s standing evidence and that rewriting it would destroy the evidence for a rule. It is not, and it does not fail: it asserts a reframe <em>carried</em> widths, which is a statement about the producer&#39;s diff. The reason for declining is therefore not the one the ticket gives — see <a href="../code/protocol.html"><code>context/code/protocol.md</code></a> §3.</p>
<h3 id="a-chord-is-recognised-by-its-modifiers-and-not-by-its-label"><a class="anchor" href="#a-chord-is-recognised-by-its-modifiers-and-not-by-its-label">#</a>a_chord_is_recognised_by_its_modifiers_and_not_by_its_label</h3>
<p><code>help::compact</code> dropped a chorded key from a status-bar hint by testing whether the formatted label started with <code>&quot;Ctrl-&quot;</code> or <code>&quot;Alt-&quot;</code> — parsing a string it had just formatted. Finding 3 of the adversarial review of PR #50, filed as <a class="tid" href="../tickets.html#improvise-thpe" title="help::compact discriminates a chord by parsing a string it formatted">improvise-thpe</a> and deliberately not fixed there: the reviewer enumerated every <code>format_key_label</code> output and none begins with either, so there was <strong>no false positive</strong>. The defect was the shape.</p>
<p><strong>The regression guard is the signature</strong>, as it is for <code>Checked</code> (gotcha 43): <code>compact</code> now takes <code>&amp;[(KeyCode, KeyModifiers)]</code>, so the string it used to parse is not in scope to parse. <code>keys_for</code> returns the pairs and <code>Hint::keys</code> carries them; <code>help.rs</code> formats last, through <code>Hint::labels</code> and the one spelling in <code>keymap::key_label</code>. <code>Keymap::labels_for</code> — the &quot;throw the modifiers away into a string&quot; step the ticket names — has <strong>no caller left and is deleted</strong>, because a consolidation that keeps its old path keeps the defect available (<code>doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)</code>).</p>
<p>The rule also got wider, and that is the part a test can state: a chord is any modifier <strong>beyond shift</strong>, which is total over the bitset, where the string test enumerated two prefixes. Shift alone is not a chord — it is how a key is named rather than how it is held, which <code>S-Tab</code> is the case for.</p>
<p><strong>This test cannot fail under the rule it names</strong>, and its doc comment says so rather than claiming otherwise. <code>CONTROL|ALT|SHIFT</code> on <code>F(5)</code> spells <code>Ctrl-Alt-F5</code>, which the old prefix test drops too. It pins the rule <code>is_chord</code> states; the <em>discrimination</em> is the injection below and the signature.</p>
<p><strong>Injected fault, run rather than argued.</strong> A <code>KeyModifiers::SUPER</code> bit and a <code>key_label</code> arm spelling it <code>Super-x</code> were added temporarily. The old filter keeps it — <code>[&quot;T&quot;, &quot;Super-x&quot;]</code>, two survivors where one is wanted — while <code>compact</code> returns <code>&quot;T&quot;</code>. That is the ticket&#39;s own reproduction, executed, and it is the only way to see the difference: with <code>KeyModifiers</code> as shipped, every chord&#39;s label <em>does</em> begin with <code>Ctrl-</code> or <code>Alt-</code>, so no permanent test can separate the two rules. Both the bit and the probe were removed afterwards.</p>
<p>One behaviour that had to be preserved deliberately: <code>keys_for</code> used to <code>dedup</code> <strong>labels</strong>, and deduping the pairs instead would let two patterns that spell the same key both reach the status bar. It now dedups by label explicitly.</p>
<p>The binding dump is byte-identical across this change (4406 lines, <code>diff</code> clean), which is what <code>doctrine(binding_dump_diff)</code> asks of anything touching the keymap: the dump&#39;s prefix rows go through <code>Hint</code>, so they had to be checked rather than assumed.</p>
</main>
</div>
</body>
</html>