Files
improvise/site/docs/test-evidence/command.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

928 lines
323 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>improvise-command — 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">improvise-protocol</a></li><li><a href="../test-evidence/command.html" class="here">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/command.md">context/test-evidence/command.md</a></p><details class="toc" open><summary>On this page</summary><ul><li class="l1"><a href="#improvise-command-test-evidence">improvise-command — test evidence</a></li><li class="l2"><a href="#ui-reduce-rs">ui/reduce.rs</a></li><li class="l3"><a href="#a-view-slice-command-does-not-move-the-authoritative-view-state">a_view_slice_command_does_not_move_the_authoritative_view_state</a></li><li class="l3"><a href="#switch-view-moves-the-client-and-leaves-the-documents-default">switch_view_moves_the_client_and_leaves_the_documents_default</a></li><li class="l3"><a href="#a-wire-view-switch-records-history-the-way-a-key-switch-does">a_wire_view_switch_records_history_the_way_a_key_switch_does</a></li><li class="l3"><a href="#switching-views-projects-the-axis-widgets-only">switching_views_projects_the_axis_widgets_only</a></li><li class="l3"><a href="#an-inbound-projection-is-ignored">an_inbound_projection_is_ignored</a></li><li class="l3"><a href="#a-keypress-that-changes-the-model-projects-it">a_keypress_that_changes_the_model_projects_it</a></li><li class="l3"><a href="#a-view-only-keypress-projects-nothing">a_view_only_keypress_projects_nothing</a></li><li class="l3"><a href="#every-model-command-names-an-effect-that-declares-a-model-change">every_model_command_names_an_effect_that_declares_a_model_change</a></li><li class="l3"><a href="#no-model-command-commits-another-clients-session-local-state">no_model_command_commits_another_clients_session_local_state</a></li><li class="l3"><a href="#the-session-local-effects-are-real-model-effects">the_session_local_effects_are_real_model_effects</a></li><li class="l3"><a href="#the-effect-name-reader-reads-all-three-debug-shapes">the_effect_name_reader_reads_all_three_debug_shapes</a></li><li class="l3"><a href="#every-model-command-names-the-effect-of-the-same-name">every_model_command_names_the_effect_of_the_same_name</a></li><li class="l3"><a href="#the-enumeration-names-each-variant-once">the_enumeration_names_each_variant_once</a></li><li class="l3"><a href="#a-model-command-lands-where-its-keypress-lands">a_model_command_lands_where_its_keypress_lands</a></li><li class="l3"><a href="#a-stale-wire-model-command-cannot-be-applied">a_stale_wire_model_command_cannot_be_applied</a></li><li class="l3"><a href="#a-model-commands-dirty-comes-from-its-effects-kind">a_model_commands_dirty_comes_from_its_effects_kind</a></li><li class="l3"><a href="#a-wire-model-command-marks-the-document-dirty">a_wire_model_command_marks_the_document_dirty</a></li><li class="l3"><a href="#a-wire-view-switch-leaves-the-document-clean">a_wire_view_switch_leaves_the_document_clean</a></li><li class="l2"><a href="#ui-effect-rs">ui/effect.rs</a></li><li class="l3"><a href="#the-document-surface-runs-on-a-non-filesystem-backend">the_document_surface_runs_on_a_non_filesystem_backend</a></li><li class="l3"><a href="#view-only-effects-leave-shared-state-alone">view_only_effects_leave_shared_state_alone</a></li><li class="l3"><a href="#structural-effects-declare-their-kind">structural_effects_declare_their_kind</a></li><li class="l3"><a href="#add-category-past-the-limit-reports-the-failure">add_category_past_the_limit_reports_the_failure</a></li><li class="l3"><a href="#add-formula-adds-target-item-to-category">add_formula_adds_target_item_to_category</a></li><li class="l3"><a href="#abort-chain-short-circuits-apply-effects">abort_chain_short_circuits_apply_effects</a></li><li class="l3"><a href="#save-without-file-path-aborts-the-chain">save_without_file_path_aborts_the_chain</a></li><li class="l3"><a href="#failed-save-as-aborts-the-chain">failed_save_as_aborts_the_chain</a></li><li class="l3"><a href="#successful-save-as-lets-the-chain-continue">successful_save_as_lets_the_chain_continue</a></li><li class="l3"><a href="#enter-edit-at-cursor-uses-target-mode-not-app-mode">enter_edit_at_cursor_uses_target_mode_not_app_mode</a></li><li class="l3"><a href="#enter-edit-at-cursor-reads-post-batch-layout">enter_edit_at_cursor_reads_post_batch_layout</a></li><li class="l3"><a href="#set-buffer-does-not-special-case-the-search-name">set_buffer_does_not_special_case_the_search_name</a></li><li class="l3"><a href="#apply-and-clear-drill-coord-rename-moves-every-measure-in-the-row">apply_and_clear_drill_coord_rename_moves_every_measure_in_the_row</a></li><li class="l3"><a href="#import-headless-csv-short-rows-surface-warning-in-status">import_headless_csv_short_rows_surface_warning_in_status</a></li><li class="l3"><a href="#every-file-writing-effect-aborts-the-batch-on-failure">every_file_writing_effect_aborts_the_batch_on_failure</a></li><li class="l3"><a href="#a-batch-that-writes-nothing-leaves-the-document-clean">a_batch_that_writes_nothing_leaves_the_document_clean</a></li><li class="l3"><a href="#a-cell-data-effect-no-raw-cell-records-still-dirties-the-document">a_cell_data_effect_no_raw_cell_records_still_dirties_the_document</a></li><li class="l3"><a href="#every-layout-geometry-effect-is-visible-to-the-narrow-fingerprint">every_layout_geometry_effect_is_visible_to_the_narrow_fingerprint</a></li><li class="l2"><a href="#ui-app-rs">ui/app.rs</a></li><li class="l3"><a href="#model-state-and-view-state-are-constructible">model_state_and_view_state_are_constructible</a></li><li class="l3"><a href="#app-view-state-owns-ui-session-fields">app_view_state_owns_ui_session_fields</a></li><li class="l3"><a href="#view-state-survives-a-json-round-trip">view_state_survives_a_json_round_trip</a></li><li class="l3"><a href="#the-documents-default-view-outlives-this-clients-switches">the_documents_default_view_outlives_this_clients_switches</a></li><li class="l3"><a href="#moving-the-cursor-does-not-touch-the-shared-workbook">moving_the_cursor_does_not_touch_the_shared_workbook</a></li><li class="l3"><a href="#app-owns-the-session-local-fields-not-view-state">app_owns_the_session_local_fields_not_view_state</a></li><li class="l3"><a href="#staged-drill-edits-are-unreachable-from-view-state">staged_drill_edits_are_unreachable_from_view_state</a></li><li class="l3"><a href="#switching-views-restores-each-views-own-cursor">switching_views_restores_each_views_own_cursor</a></li><li class="l3"><a href="#the-improv-format-does-not-persist-the-cursor">the_improv_format_does_not_persist_the_cursor</a></li><li class="l3"><a href="#entering-records-mode-sorts-existing-data">entering_records_mode_sorts_existing_data</a></li><li class="l3"><a href="#the-new-category-column-creates-a-category-and-its-column">the_new_category_column_creates_a_category_and_its_column</a></li><li class="l3"><a href="#enter-on-plus-cat-in-the-last-row-does-not-reopen-category-creation">enter_on_plus_cat_in_the_last_row_does_not_reopen_category_creation</a></li><li class="l3"><a href="#tab-on-plus-cat-does-not-reopen-category-creation">tab_on_plus_cat_does_not_reopen_category_creation</a></li><li class="l3"><a href="#the-new-category-column-creates-the-column-but-writes-no-coordinate">the_new_category_column_creates_the_column_but_writes_no_coordinate</a></li><li class="l3"><a href="#the-new-category-column-refuses-past-the-limit-where-the-user-can-see-it">the_new_category_column_refuses_past_the_limit_where_the_user_can_see_it</a></li><li class="l3"><a href="#naming-a-category-in-records-mode-does-not-eject-the-user">naming_a_category_in_records_mode_does_not_eject_the_user</a></li><li class="l3"><a href="#i-and-o-diverge-on-an-empty-records-grid">i_and_o_diverge_on_an_empty_records_grid</a></li><li class="l3"><a href="#the-empty-grid-message-is-not-the-records-one-in-pivot-mode">the_empty_grid_message_is_not_the_records_one_in_pivot_mode</a></li><li class="l3"><a href="#blank-model-bootstraps-its-first-measure-from-the-new-measure-column">blank_model_bootstraps_its_first_measure_from_the_new_measure_column</a></li><li class="l3"><a href="#ctrl-w-prompts-for-a-path-and-a-failed-write-reports-it-where-it-shows">ctrl_w_prompts_for_a_path_and_a_failed_write_reports_it_where_it_shows</a></li><li class="l3"><a href="#every-minibuffer-mode-entered-from-records-returns-to-records-normal">every_minibuffer_mode_entered_from_records_returns_to_records_normal</a></li><li class="l3"><a href="#a-colon-line-run-from-records-settles-back-into-records-normal">a_colon_line_run_from_records_settles_back_into_records_normal</a></li><li class="l3"><a href="#a-batch-that-leaves-records-and-then-sets-normal-lands-in-normal">a_batch_that_leaves_records_and_then_sets_normal_lands_in_normal</a></li><li class="l3"><a href="#a-wizard-abandoned-over-a-records-grid-returns-to-records-normal">a_wizard_abandoned_over_a_records_grid_returns_to_records_normal</a></li><li class="l3"><a href="#a-failed-colon-write-lands-in-normal-where-its-error-is-drawn">a_failed_colon_write_lands_in_normal_where_its_error_is_drawn</a></li><li class="l3"><a href="#a-failed-colon-write-still-refuses-to-quit-after-it">a_failed_colon_write_still_refuses_to_quit_after_it</a></li><li class="l3"><a href="#a-sequence-whose-first-step-aborts-does-not-run-the-second">a_sequence_whose_first_step_aborts_does_not_run_the_second</a></li><li class="l3"><a href="#unmatched-key-after-prefix-is-swallowed">unmatched_key_after_prefix_is_swallowed</a></li><li class="l3"><a href="#renaming-a-coordinate-moves-every-measure-in-the-row">renaming_a_coordinate_moves_every_measure_in_the_row</a></li><li class="l3"><a href="#leaving-records-mode-cleans-empty-key-cells">leaving_records_mode_cleans_empty_key_cells</a></li><li class="l3"><a href="#enter-at-bottom-right-of-records-view-exits-editing">enter_at_bottom_right_of_records_view_exits_editing</a></li><li class="l3"><a href="#tab-on-bottom-right-of-records-inserts-below">tab_on_bottom_right_of_records_inserts_below</a></li><li class="l3"><a href="#name-model-typing-edits-the-name-and-enter-confirms-the-import">name_model_typing_edits_the_name_and_enter_confirms_the_import</a></li><li class="l3"><a href="#review-proposals-blocks-when-no-category-is-accepted">review_proposals_blocks_when_no_category_is_accepted</a></li><li class="l3"><a href="#left-walks-back-through-the-steps-taken">left_walks_back_through_the_steps_taken</a></li><li class="l3"><a href="#done-esc-returns-to-name-model-and-drops-the-built-model">done_esc_returns_to_name_model_and_drops_the_built_model</a></li><li class="l3"><a href="#a-records-two-measures-share-one-record-key">a_records_two_measures_share_one_record_key</a></li><li class="l3"><a href="#a-layout-change-moves-the-layout-version-and-no-record">a_layout_change_moves_the_layout_version_and_no_record</a></li><li class="l3"><a href="#a-loaded-document-is-dirty-because-its-path-still-names-the-old-one">a_loaded_document_is_dirty_because_its_path_still_names_the_old_one</a></li><li class="l3"><a href="#a-view-only-batch-leaves-the-document-clean">a_view_only_batch_leaves_the_document_clean</a></li><li class="l3"><a href="#a-cell-edit-moves-only-the-record-it-touched">a_cell_edit_moves_only_the_record_it_touched</a></li><li class="l3"><a href="#a-coordinate-rename-is-one-write-to-each-record-not-one-per-measure">a_coordinate_rename_is_one_write_to_each_record_not_one_per_measure</a></li><li class="l3"><a href="#an-aborted-batch-bumps-nothing-it-did-not-run">an_aborted_batch_bumps_nothing_it_did_not_run</a></li><li class="l3"><a href="#a-write-that-changes-nothing-moves-no-version">a_write_that_changes_nothing_moves_no_version</a></li><li class="l3"><a href="#the-version-tiers-are-not-in-the-view-state">the_version_tiers_are_not_in_the_view_state</a></li><li class="l3"><a href="#a-page-flip-under-prune-empty-invalidates-the-cursor">a_page_flip_under_prune_empty_invalidates_the_cursor</a></li><li class="l3"><a href="#a-page-flip-without-prune-empty-leaves-the-cursor-alone">a_page_flip_without_prune_empty_leaves_the_cursor_alone</a></li><li class="l3"><a href="#selecting-the-page-already-selected-invalidates-no-cursor">selecting_the_page_already_selected_invalidates_no_cursor</a></li><li class="l3"><a href="#a-foreign-layout-is-not-shown-the-hosts-staged-drill-text">a_foreign_layout_is_not_shown_the_hosts_staged_drill_text</a></li><li class="l3"><a href="#a-default-that-names-nothing-says-which-invariant-broke">a_default_that_names_nothing_says_which_invariant_broke</a></li><li class="l2"><a href="#ui-project-rs">ui/project.rs</a></li><li class="l3"><a href="#a-window-reaching-the-last-row-carries-the-trailing-group-header">a_window_reaching_the_last_row_carries_the_trailing_group_header</a></li><li class="l3"><a href="#a-window-that-stops-short-does-not-carry-what-is-below-it">a_window_that_stops_short_does_not_carry_what_is_below_it</a></li><li class="l3"><a href="#a-window-opening-inside-a-group-names-only-the-nearest">a_window_opening_inside_a_group_names_only_the_nearest</a></li><li class="l3"><a href="#consecutive-headers-between-two-rows-all-come">consecutive_headers_between_two_rows_all_come</a></li><li class="l3"><a href="#folding-what-the-server-emits-reproduces-what-the-server-has">folding_what_the_server_emits_reproduces_what_the_server_has</a></li><li class="l3"><a href="#removing-a-category-empties-the-clients-formula-panel">removing_a_category_empties_the_clients_formula_panel</a></li><li class="l3"><a href="#a-late-client-converges-with-one-that-followed-from-the-start">a_late_client_converges_with_one_that_followed_from_the_start</a></li><li class="l3"><a href="#the-projected-extent-is-the-whole-grid-not-the-window">the_projected_extent_is_the_whole_grid_not_the_window</a></li><li class="l3"><a href="#a-reshaped-grid-folds-to-the-desired-state">a_reshaped_grid_folds_to_the_desired_state</a></li><li class="l3"><a href="#a-renamed-column-whose-total-did-not-change-still-gets-its-total-back">a_renamed_column_whose_total_did_not_change_still_gets_its_total_back</a></li><li class="l3"><a href="#renaming-an-axis-category-resends-every-cell">renaming_an_axis_category_resends_every_cell</a></li><li class="l3"><a href="#a-session-that-switches-views-is-projected-the-view-it-switched-to">a_session_that_switches_views_is_projected_the_view_it_switched_to</a></li><li class="l3"><a href="#a-view-switch-moves-only-the-switchers-mirror">a_view_switch_moves_only_the_switchers_mirror</a></li><li class="l3"><a href="#a-refused-switch-corrects-the-asker-and-nobody-else">a_refused_switch_corrects_the_asker_and_nobody_else</a></li><li class="l3"><a href="#deleting-the-view-this-client-is-on-moves-it-to-the-default">deleting_the_view_this_client_is_on_moves_it_to_the_default</a></li><li class="l3"><a href="#deleting-a-view-moves-the-client-that-was-on-it-and-tells-it">deleting_a_view_moves_the_client_that_was_on_it_and_tells_it</a></li><li class="l3"><a href="#a-session-on-another-view-is-projected-that-views-axes">a_session_on_another_view_is_projected_that_views_axes</a></li><li class="l3"><a href="#walking-the-view-history-moves-no-document-state">walking_the_view_history_moves_no_document_state</a></li><li class="l3"><a href="#a-fresh-session-opens-the-documents-default-view">a_fresh_session_opens_the_documents_default_view</a></li><li class="l3"><a href="#a-geometry-command-invalidates-every-clients-cursor-including-the-askers">a_geometry_command_invalidates_every_clients_cursor_including_the_askers</a></li><li class="l3"><a href="#a-model-command-that-does-not-reshape-the-grid-leaves-cursors-alone">a_model_command_that_does_not_reshape_the_grid_leaves_cursors_alone</a></li><li class="l3"><a href="#a-bystanders-own-slice-follows-the-invalidation">a_bystanders_own_slice_follows_the_invalidation</a></li><li class="l3"><a href="#a-model-command-that-leaves-the-active-view-alone-broadcasts-no-switch">a_model_command_that_leaves_the_active_view_alone_broadcasts_no_switch</a></li><li class="l3"><a href="#every-reshaping-model-command-invalidates-every-cursor">every_reshaping_model_command_invalidates_every_cursor</a></li><li class="l3"><a href="#drilling-resets-only-the-drill-views-cursor">drilling_resets_only_the_drill_views_cursor</a></li><li class="l3"><a href="#an-accepted-no-op-invalidates-no-cursor">an_accepted_no_op_invalidates_no_cursor</a></li><li class="l3"><a href="#naming-a-measure-at-the-bottom-right-leaves-the-cursor-put">naming_a_measure_at_the_bottom_right_leaves_the_cursor_put</a></li><li class="l3"><a href="#a-refused-model-command-invalidates-no-cursor">a_refused_model_command_invalidates_no_cursor</a></li><li class="l3"><a href="#a-clients-view-only-command-moves-only-that-clients-mirror">a_clients_view_only_command_moves_only_that_clients_mirror</a></li><li class="l3"><a href="#a-document-swap-reseeds-every-session">a_document_swap_reseeds_every_session</a></li><li class="l3"><a href="#rebase-hands-every-session-a-snapshot-of-the-new-document">rebase_hands_every_session_a_snapshot_of_the_new_document</a></li><li class="l3"><a href="#a-document-swap-over-the-wire-rebases-every-client">a_document_swap_over_the_wire_rebases_every_client</a></li><li class="l3"><a href="#a-command-that-leaves-the-document-in-place-is-not-a-rebase">a_command_that_leaves_the_document_in_place_is_not_a_rebase</a></li><li class="l3"><a href="#ui-project-tests-a-load-that-fails-is-not-a-rebase">ui::project::tests::a_load_that_fails_is_not_a_rebase</a></li><li class="l3"><a href="#the-two-measures-of-a-row-share-a-record-and-differ-by-measure">the_two_measures_of_a_row_share_a_record_and_differ_by_measure</a></li><li class="l3"><a href="#a-projected-record-id-resolves-to-the-cell-it-was-projected-for">a_projected_record_id_resolves_to_the_cell_it_was_projected_for</a></li><li class="l3"><a href="#a-projected-aggregate-id-resolves-to-the-same-constraint">a_projected_aggregate_id_resolves_to_the_same_constraint</a></li><li class="l3"><a href="#a-surrogate-for-a-removed-row-fails-to-resolve-and-the-others-do-not">a_surrogate_for_a_removed_row_fails_to_resolve_and_the_others_do_not</a></li><li class="l3"><a href="#an-empty-cell-can-be-written-through-its-projected-id">an_empty_cell_can_be_written_through_its_projected_id</a></li><li class="l3"><a href="#a-write-by-surrogate-lands-on-exactly-that-cell">a_write_by_surrogate_lands_on_exactly_that_cell</a></li><li class="l3"><a href="#a-write-through-a-dead-surrogate-changes-nothing">a_write_through_a_dead_surrogate_changes_nothing</a></li><li class="l3"><a href="#a-records-mode-cell-is-named-by-its-coordinates-not-by-its-position">a_records_mode_cell_is_named_by_its_coordinates_not_by_its_position</a></li><li class="l3"><a href="#the-measure-columns-of-a-record-row-share-its-record">the_measure_columns_of_a_record_row_share_its_record</a></li><li class="l3"><a href="#a-coordinate-column-is-projected-with-no-identity">a_coordinate_column_is_projected_with_no_identity</a></li><li class="l3"><a href="#a-write-moves-the-version-under-the-clients-own-surrogate">a_write_moves_the_version_under_the_clients_own_surrogate</a></li><li class="l3"><a href="#a-cell-edit-is-rejected-when-the-layout-moved-under-it">a_cell_edit_is_rejected_when_the_layout_moved_under_it</a></li><li class="l3"><a href="#a-cell-edit-composed-after-the-reshape-is-applied">a_cell_edit_composed_after_the_reshape_is_applied</a></li><li class="l3"><a href="#a-write-is-rejected-when-the-record-it-names-moved">a_write_is_rejected_when_the_record_it_names_moved</a></li><li class="l3"><a href="#a-write-survives-another-record-moving">a_write_survives_another_record_moving</a></li><li class="l3"><a href="#a-write-to-a-record-that-no-longer-exists-says-so">a_write_to_a_record_that_no_longer_exists_says_so</a></li><li class="l3"><a href="#a-claim-that-omits-the-record-it-writes-is-not-a-free-pass">a_claim_that_omits_the_record_it_writes_is_not_a_free_pass</a></li><li class="l3"><a href="#a-document-swap-invalidates-every-claim">a_document_swap_invalidates_every_claim</a></li><li class="l3"><a href="#a-rejected-batch-changes-nothing-anywhere">a_rejected_batch_changes_nothing_anywhere</a></li><li class="l3"><a href="#a-published-basis-follows-the-grid-updates-it-describes">a_published_basis_follows_the_grid_updates_it_describes</a></li><li class="l3"><a href="#a-client-that-folds-the-stream-composes-an-accepted-batch">a_client_that_folds_the_stream_composes_an_accepted_batch</a></li><li class="l3"><a href="#re-composing-against-the-republished-basis-is-accepted">re_composing_against_the_republished_basis_is_accepted</a></li><li class="l2"><a href="#ui-host-rs">ui/host.rs</a></li><li class="l3"><a href="#connecting-hands-that-client-a-snapshot-that-draws-the-document">connecting_hands_that_client_a_snapshot_that_draws_the_document</a></li><li class="l3"><a href="#the-reported-extent-bounds-the-snapshot">the_reported_extent_bounds_the_snapshot</a></li><li class="l3"><a href="#without-the-command-the-cell-is-empty">without_the_command_the_cell_is_empty</a></li><li class="l3"><a href="#a-bystander-is-projected-another-clients-edit">a_bystander_is_projected_another_clients_edit</a></li><li class="l3"><a href="#a-scroll-reaches-only-the-client-that-scrolled">a_scroll_reaches_only_the_client_that_scrolled</a></li><li class="l3"><a href="#a-batch-of-commands-is-applied-whole-and-in-order">a_batch_of_commands_is_applied_whole_and_in_order</a></li><li class="l3"><a href="#a-batch-is-abandoned-at-the-first-rejection">a_batch_is_abandoned_at_the_first_rejection</a></li><li class="l3"><a href="#a-refused-batch-leaves-the-commands-before-it-unapplied">a_refused_batch_leaves_the_commands_before_it_unapplied</a></li><li class="l3"><a href="#a-later-write-in-a-chain-still-answers-for-its-own-record">a_later_write_in_a_chain_still_answers_for_its_own_record</a></li><li class="l3"><a href="#a-data-dependent-chain-lands-in-order">a_data_dependent_chain_lands_in_order</a></li><li class="l3"><a href="#a-frame-whose-later-mutation-fails-is-not-yet-undone">a_frame_whose_later_mutation_fails_is_not_yet_undone</a></li><li class="l3"><a href="#an-accepted-switch-is-relayed-to-nobody">an_accepted_switch_is_relayed_to_nobody</a></li><li class="l3"><a href="#a-geometry-command-relays-the-cursor-reset-to-the-asker-too">a_geometry_command_relays_the_cursor_reset_to_the_asker_too</a></li><li class="l3"><a href="#a-stale-batch-is-refused-to-the-asker-alone">a_stale_batch_is_refused_to_the_asker_alone</a></li><li class="l3"><a href="#folding-the-relay-puts-a-refused-asker-back">folding_the_relay_puts_a_refused_asker_back</a></li><li class="l3"><a href="#a-document-swap-sends-every-client-a-fresh-snapshot">a_document_swap_sends_every_client_a_fresh_snapshot</a></li><li class="l3"><a href="#a-client-can-ask-its-host-to-save">a_client_can_ask_its_host_to_save</a></li><li class="l3"><a href="#a-save-with-nowhere-to-write-is-refused-and-told-only-to-the-asker">a_save_with_nowhere_to_write_is_refused_and_told_only_to_the_asker</a></li><li class="l3"><a href="#a-write-that-fails-is-refused-rather-than-reported-as-saved">a_write_that_fails_is_refused_rather_than_reported_as_saved</a></li><li class="l3"><a href="#every-frame-survives-a-json-round-trip">every_frame_survives_a_json_round_trip</a></li><li class="l2"><a href="#ui-cat-tree-rs">ui/cat_tree.rs</a></li><li class="l3"><a href="#the-tree-carries-the-virtual-categories-and-measure-includes-formula-targets">the_tree_carries_the_virtual_categories_and_measure_includes_formula_targets</a></li><li class="l2"><a href="#command-keymap-rs">command/keymap.rs</a></li><li class="l3"><a href="#f1-and-question-mark-both-open-help">f1_and_question_mark_both_open_help</a></li><li class="l3"><a href="#every-bound-command-name-is-registered">every_bound_command_name_is_registered</a></li><li class="l3"><a href="#prefix-keymaps-are-flat">prefix_keymaps_are_flat</a></li><li class="l3"><a href="#every-panel-keymap-shares-the-same-spine">every_panel_keymap_shares_the_same_spine</a></li><li class="l3"><a href="#every-mode-with-panel-toggles-binds-all-of-them">every_mode_with_panel_toggles_binds_all_of_them</a></li><li class="l3"><a href="#panel-toggle-keys-dispatch-to-real-effects">panel_toggle_keys_dispatch_to_real_effects</a></li><li class="l3"><a href="#every-text-entry-mode-types-into-the-buffer-its-prompt-names">every_text_entry_mode_types_into_the_buffer_its_prompt_names</a></li><li class="l3"><a href="#every-typing-keymap-belongs-to-a-text-entry-mode">every_typing_keymap_belongs_to_a_text_entry_mode</a></li><li class="l3"><a href="#text-entry-modes-clear-the-buffer-they-type-into">text_entry_modes_clear_the_buffer_they_type_into</a></li><li class="l3"><a href="#minibuffer-enter-dispatches-to-a-registered-commit">minibuffer_enter_dispatches_to_a_registered_commit</a></li><li class="l3"><a href="#dump-every-binding">dump_every_binding</a></li><li class="l2"><a href="#command-cmd-commit-rs">command/cmd/commit.rs</a></li><li class="l3"><a href="#invalid-formula-reports-the-parse-error-not-success">invalid_formula_reports_the_parse_error_not_success</a></li><li class="l3"><a href="#commit-and-advance-threads-edit-mode-to-enter-edit-at-cursor">commit_and_advance_threads_edit_mode_to_enter_edit_at_cursor</a></li><li class="l3"><a href="#commit-save-writes-the-model-to-the-typed-path">commit_save_writes_the_model_to_the_typed_path</a></li><li class="l3"><a href="#commit-save-on-an-empty-path-writes-nothing">commit_save_on_an_empty_path_writes_nothing</a></li><li class="l3"><a href="#commit-export-bails-on-an-empty-path">commit_export_bails_on_an_empty_path</a></li><li class="l2"><a href="#command-cmd-fit-rs">command/cmd/fit.rs</a></li><li class="l3"><a href="#the-command-installs-a-formula-the-document-evaluates">the_command_installs_a_formula_the_document_evaluates</a></li><li class="l3"><a href="#the-status-line-reports-quality-against-the-baseline">the_status_line_reports_quality_against_the_baseline</a></li><li class="l3"><a href="#a-refusal-names-what-went-wrong-and-stores-nothing">a_refusal_names_what_went_wrong_and_stores_nothing</a></li><li class="l3"><a href="#the-seed-argument-changes-the-search">the_seed_argument_changes_the_search</a></li><li class="l3"><a href="#the-seed-the-status-line-prints-is-a-seed-this-command-accepts">the_seed_the_status_line_prints_is_a_seed_this_command_accepts</a></li><li class="l2"><a href="#command-cmd-grid-rs">command/cmd/grid.rs</a></li><li class="l3"><a href="#drill-into-formula-cell-returns-data-records">drill_into_formula_cell_returns_data_records</a></li><li class="l3"><a href="#drilling-fixes-its-coordinates-on-filter-not-page">drilling_fixes_its_coordinates_on_filter_not_page</a></li><li class="l2"><a href="#command-cmd-mode-rs">command/cmd/mode.rs</a></li><li class="l3"><a href="#enter-mode-export-prompt-sets-mode-and-clears-its-buffer">enter_mode_export_prompt_sets_mode_and_clears_its_buffer</a></li><li class="l3"><a href="#enter-mode-clears-a-search-keyed-minibuffer-through-write-buffer">enter_mode_clears_a_search_keyed_minibuffer_through_write_buffer</a></li><li class="l3"><a href="#edit-or-drill-passes-records-editing-mode-through">edit_or_drill_passes_records_editing_mode_through</a></li><li class="l3"><a href="#enter-edit-at-cursor-cmd-passes-target-mode-to-effect">enter_edit_at_cursor_cmd_passes_target_mode_to_effect</a></li><li class="l2"><a href="#command-cmd-navigation-rs">command/cmd/navigation.rs</a></li><li class="l3"><a href="#page-cycling-skips-filter-coordinates">page_cycling_skips_filter_coordinates</a></li><li class="l3"><a href="#enter-advance-at-bottom-right-emits-abort-chain">enter_advance_at_bottom_right_emits_abort_chain</a></li><li class="l2"><a href="#command-cmd-panel-rs">command/cmd/panel.rs</a></li><li class="l3"><a href="#cycle-panel-focus-advances-from-formula-to-category">cycle_panel_focus_advances_from_formula_to_category</a></li><li class="l3"><a href="#create-and-switch-view-names-incrementally">create_and_switch_view_names_incrementally</a></li><li class="l2"><a href="#command-cmd-registry-rs">command/cmd/registry.rs</a></li><li class="l3"><a href="#every-entry-constructs-a-command-with-its-registered-name">every_entry_constructs_a_command_with_its_registered_name</a></li><li class="l3"><a href="#registered-names-are-unique">registered_names_are_unique</a></li><li class="l3"><a href="#the-context-reports-each-panels-own-open-flag">the_context_reports_each_panels_own_open_flag</a></li><li class="l3"><a href="#toggle-panel-visibility-reads-the-panel-it-names">toggle_panel_visibility_reads_the_panel_it_names</a></li><li class="l3"><a href="#a-focused-open-panel-closes-and-a-closed-one-opens">a_focused_open_panel_closes_and_a_closed_one_opens</a></li><li class="l3"><a href="#helper-open-only">Helper — open_only</a></li><li class="l2"><a href="#command-cmd-text-buffer-rs">command/cmd/text_buffer.rs</a></li><li class="l3"><a href="#writing-the-search-buffer-targets-the-search-query">writing_the_search_buffer_targets_the_search_query</a></li><li class="l3"><a href="#colon-wq-saves-then-quits">colon_wq_saves_then_quits</a></li><li class="l3"><a href="#colon-enter-edit-at-cursor-is-not-snapped-back-to-normal">colon_enter_edit_at_cursor_is_not_snapped_back_to_normal</a></li><li class="l3"><a href="#colon-import-stays-in-the-wizard-on-success">colon_import_stays_in_the_wizard_on_success</a></li><li class="l3"><a href="#colon-import-falls-back-to-normal-on-failure">colon_import_falls_back_to_normal_on_failure</a></li><li class="l3"><a href="#colon-view-back-respects-whether-history-exists">colon_view_back_respects_whether_history_exists</a></li><li class="l2"><a href="#command-cmd-wizard-rs">command/cmd/wizard.rs</a></li><li class="l3"><a href="#name-model-enter-builds-the-model">name_model_enter_builds_the_model</a></li><li class="l3"><a href="#done-confirms-on-enter-and-steps-back-on-esc">done_confirms_on_enter_and_steps_back_on_esc</a></li><li class="l3"><a href="#a-formula-owned-cell-is-projected-as-derived-and-its-siblings-are-not">a_formula_owned_cell_is_projected_as_derived_and_its_siblings_are_not</a></li><li class="l3"><a href="#an-aggregating-formula-cell-is-still-projected-as-an-aggregate">an_aggregating_formula_cell_is_still_projected_as_an_aggregate</a></li><li class="l3"><a href="#edit-or-drill-refuses-a-formula-owned-cell">edit_or_drill_refuses_a_formula_owned_cell</a></li><li class="l3"><a href="#a-save-clears-the-dirty-marker-on-a-client-although-nothing-is-dirty">a_save_clears_the_dirty_marker_on_a_client_although_nothing_is_dirty</a></li><li class="l3"><a href="#the-title-bar-names-the-document-without-naming-the-path">the_title_bar_names_the_document_without_naming_the_path</a></li><li class="l3"><a href="#a-creating-column-is-named-rather-than-left-as-no-cell">a_creating_column_is_named_rather_than_left_as_no_cell</a></li><li class="l3"><a href="#a-records-coordinate-is-text-however-it-is-spelled">a_records_coordinate_is_text_however_it_is_spelled</a></li><li class="l3"><a href="#a-staged-drill-edit-reaches-its-own-session-and-no-projection">a_staged_drill_edit_reaches_its_own_session_and_no_projection</a></li><li class="l3"><a href="#a-page-axis-carries-its-items-and-a-filter-axis-does-not">a_page_axis_carries_its_items_and_a_filter_axis_does_not</a></li><li class="l3"><a href="#a-records-grid-has-no-column-for-a-formula-target">a_records_grid_has_no_column_for_a_formula_target</a></li><li class="l3"><a href="#a-records-column-a-formula-owns-is-derived-not-a-record">a_records_column_a_formula_owns_is_derived_not_a_record</a></li><li class="l3"><a href="#typing-into-a-records-formula-column-is-refused-on-the-key-path">typing_into_a_records_formula_column_is_refused_on_the_key_path</a></li><li class="l3"><a href="#a-save-tells-the-client-the-document-is-clean">a_save_tells_the_client_the_document_is_clean</a></li><li class="l3"><a href="#the-automatic-persist-tells-the-client-the-document-is-clean">the_automatic_persist_tells_the_client_the_document_is_clean</a></li><li class="l3"><a href="#a-refused-or-duplicate-add-category-moves-no-counter">a_refused_or_duplicate_add_category_moves_no_counter</a></li><li class="l3"><a href="#both-shape-counters-move-for-every-reshaping-command">both_shape_counters_move_for_every_reshaping_command</a></li><li class="l3"><a href="#a-configuration-change-that-draws-nothing-is-still-a-document-change">a_configuration_change_that_draws_nothing_is_still_a_document_change</a></li><li class="l3"><a href="#a-reshape-of-one-view-leaves-a-client-on-another-view-alone">a_reshape_of_one_view_leaves_a_client_on_another_view_alone</a></li><li class="l3"><a href="#an-unknown-reshape-set-invalidates-everybody">an_unknown_reshape_set_invalidates_everybody</a></li><li class="l3"><a href="#a-batch-that-changes-the-layout-and-then-saves-ends-clean">a_batch_that_changes_the_layout_and_then_saves_ends_clean</a></li><li class="l3"><a href="#the-published-basis-names-the-records-the-grid-draws-and-no-others">the_published_basis_names_the_records_the_grid_draws_and_no_others</a></li><li class="l3"><a href="#an-aborted-batch-bumps-layout-version-for-what-it-did-run">an_aborted_batch_bumps_layout_version_for_what_it_did_run</a></li><li class="l3"><a href="#sorting-the-store-in-records-mode-invalidates-the-cursor">sorting_the_store_in_records_mode_invalidates_the_cursor</a></li><li class="l3"><a href="#paging-with-nothing-pruned-invalidates-no-cursor">paging_with_nothing_pruned_invalidates_no_cursor</a></li><li class="l3"><a href="#a-write-that-prunes-a-row-out-of-another-clients-view-invalidates-it">a_write_that_prunes_a_row_out_of_another_clients_view_invalidates_it</a></li><li class="l3"><a href="#a-switch-then-a-geometry-effect-compares-within-one-view">a_switch_then_a_geometry_effect_compares_within_one_view</a></li><li class="l3"><a href="#a-geometry-effect-after-a-mid-batch-switch-still-invalidates-the-cursor">a_geometry_effect_after_a_mid_batch_switch_still_invalidates_the_cursor</a></li><li class="l3"><a href="#a-frozen-drill-does-not-take-its-opening-frame-from-the-held-layout">a_frozen_drill_does_not_take_its_opening_frame_from_the_held_layout</a></li><li class="l3"><a href="#a-clients-grid-does-not-depend-on-which-view-its-host-is-on">a_clients_grid_does_not_depend_on_which_view_its_host_is_on</a></li><li class="l3"><a href="#a-window-narrows-the-cells-and-neither-the-totals-nor-the-widths">a_window_narrows_the_cells_and_neither_the_totals_nor_the_widths</a></li><li class="l3"><a href="#a-formulas-columns-survive-a-host-that-aggregates-differently">a_formulas_columns_survive_a_host_that_aggregates_differently</a></li><li class="l3"><a href="#a-view-named-command-lands-on-the-view-it-names">a_view_named_command_lands_on_the_view_it_names</a></li><li class="l3"><a href="#a-reframe-of-a-view-the-host-is-not-on-invalidates-its-clients">a_reframe_of_a_view_the_host_is_not_on_invalidates_its_clients</a></li><li class="l3"><a href="#a-command-naming-a-view-the-document-has-not-got-writes-nothing">a_command_naming_a_view_the_document_has_not_got_writes_nothing</a></li><li class="l3"><a href="#the-diff-tells-a-client-the-grid-became-a-records-grid">the_diff_tells_a_client_the_grid_became_a_records_grid</a></li><li class="l3"><a href="#a-batch-that-leaves-records-settles-out-of-records-normal">a_batch_that_leaves_records_settles_out_of_records_normal</a></li><li class="l3"><a href="#switching-views-across-records-ness-settles-against-the-view-switched-to">switching_views_across_records_ness_settles_against_the_view_switched_to</a></li><li class="l3"><a href="#the-layout-geometry-cases-are-every-wire-effect-of-that-kind">the_layout_geometry_cases_are_every_wire_effect_of_that_kind</a></li><li class="l3"><a href="#the-enumeration-names-every-variant-the-derive-declares">the_enumeration_names_every_variant_the_derive_declares</a></li></ul></details><h1 id="improvise-command-test-evidence"><code>improvise-command</code> — test evidence</h1>
<p>Why each of these tests exists, what bug it reproduced, and what fault was injected to prove it can fail. Keyed by <strong>exact function name</strong>; a rename orphans the entry, and <code>scripts/check-comment-runs.py --check-evidence</code> reports that (<a class="tid" href="../tickets.html#improvise-9c8c" title="Move the reasoning out of code comments into context/ prose, cap comments at two lines">improvise-9c8c</a>, design-principles §7).</p>
<p>Entries cover tests and the helpers whose <em>shape</em> is the evidence.</p>
<hr />
<h2 id="ui-reduce-rs"><a class="anchor" href="#ui-reduce-rs">#</a><code>ui/reduce.rs</code></h2>
<h3 id="a-view-slice-command-does-not-move-the-authoritative-view-state"><a class="anchor" href="#a-view-slice-command-does-not-move-the-authoritative-view-state">#</a>a_view_slice_command_does_not_move_the_authoritative_view_state</h3>
<p>This test used to assert the opposite, under the name <code>view_slice_commands_land_in_the_authoritative_view_state</code>, and it passed against a path no host walks (<a class="tid" href="../tickets.html#improvise-s17o" title="reduce_full and apply_command&#39;s view-slice arm have no production caller">improvise-s17o</a>): <code>apply_command</code>&#39;s fallback arm folded a wire view command into <code>app.view_state</code>, the <em>authoritative</em> slice. Nothing in production reached it — <code>Sessions::apply_checked</code> folds a view command into the asking client&#39;s mirror — so the behaviour it pinned was wrong and safe only because it was dead. The model is shared, the UI is not (principle 5, gotcha 36); a client&#39;s <code>SetStatus</code> must not move the host&#39;s status line.</p>
<p>The fixture writes <code>&quot;host&quot;</code> into the status line first, so the assertion distinguishes &quot;the fold was removed&quot; from &quot;the field happened to be empty&quot; — with the default empty string, deleting the fold and leaving it in place look the same for <code>SetStatus</code>. Restoring <code>reduce_view(&amp;mut app.view_state, other)</code> fails on the first field.</p>
<h4 id="helper-checked"><a class="anchor" href="#helper-checked">#</a>Helper — <code>checked</code></h4>
<p>Not a test — the fixture every <code>reduce.rs</code> test below reaches the door through.</p>
<p>For the tests whose subject is not staleness: a command the current state accepts, which every command built by <code>caught_up</code> is by construction. The tests whose subject <em>is</em> staleness call <code>Checked::against</code> themselves and assert on the <code>Rejection</code>, so neither kind can pass by accident through the other&#39;s convenience.</p>
<h3 id="switch-view-moves-the-client-and-leaves-the-documents-default"><a class="anchor" href="#switch-view-moves-the-client-and-leaves-the-documents-default">#</a>switch_view_moves_the_client_and_leaves_the_documents_default</h3>
<p>The successor to <code>switch_view_moves_the_document_and_the_client_together</code>, which asserted the pre-<a class="tid" href="../tickets.html#improvise-jytm" title="Split active_view: document default vs per-client authoritative (improvise-g0f)">improvise-jytm</a> lockstep. Which view a client is on is now that client&#39;s; <code>Workbook::active_view</code> is the <em>default</em> a fresh client opens and what <code>.improv</code> writes. The fault it rules out is the old behaviour returning by inertia — any writer that moves the workbook&#39;s copy from a client&#39;s switch fails it.</p>
<p>Fault injected 2026-09-20: restoring <code>App::switch_to_view</code>&#39;s <code>workbook.switch_view(name)</code> call fails this and <code>the_documents_default_view_outlives_this_clients_switches</code>.</p>
<h3 id="a-wire-view-switch-records-history-the-way-a-key-switch-does"><a class="anchor" href="#a-wire-view-switch-records-history-the-way-a-key-switch-does">#</a>a_wire_view_switch_records_history_the_way_a_key_switch_does</h3>
<p>A wire-driven view switch owes the same history a key-driven one leaves behind.</p>
<p><code>effect::ViewBack</code> pops <code>view_state.view_back_stack</code>, and the only thing that ever pushes onto it is <code>effect::SwitchView</code>. While <code>apply_command</code> performed the switch itself — calling <code>App::switch_to_view</code> directly rather than running the effect — the stack stayed empty, so a client that switched over the wire could not go back, though the same two keypresses could. Wave 4 is what made that reachable: before <code>ModelCommand</code> there was no wire way to ask.</p>
<p>Nothing else in the suite could see it. <code>switch_view_moves_the_client_and_leaves_the_documents_default</code> checks where a switch <em>landed</em>; this checks what it <em>recorded</em>.</p>
<p>Bug demonstrated 2026-09-19 (Copilot, PR #11): the <code>ViewBack</code> left the document on &quot;Second&quot;. Since <a class="tid" href="../tickets.html#improvise-jytm" title="Split active_view: document default vs per-client authoritative (improvise-g0f)">improvise-jytm</a> the step back is driven through <code>effect::ViewBack</code> rather than <code>ModelCommand::ViewBack</code>, which left the wire with that ticket — the stack it pops is one client&#39;s history, so a nullary wire command could not address it (gotcha 38). What the test asserts is unchanged.</p>
<h3 id="switching-views-projects-the-axis-widgets-only"><a class="anchor" href="#switching-views-projects-the-axis-widgets-only">#</a>switching_views_projects_the_axis_widgets_only</h3>
<p>Switching views changes what the axis-bearing widgets read, so they are told; the list of views itself did not change, so it is not.</p>
<h3 id="an-inbound-projection-is-ignored"><a class="anchor" href="#an-inbound-projection-is-ignored">#</a>an_inbound_projection_is_ignored</h3>
<p>Projections travel one way. A client that sends one back must not be able to write it into the server&#39;s state.</p>
<h3 id="a-keypress-that-changes-the-model-projects-it"><a class="anchor" href="#a-keypress-that-changes-the-model-projects-it">#</a>a_keypress_that_changes_the_model_projects_it</h3>
<p>The key path is the model&#39;s door, and it reports the widgets it touched so a host can project them.</p>
<h3 id="a-view-only-keypress-projects-nothing"><a class="anchor" href="#a-view-only-keypress-projects-nothing">#</a>a_view_only_keypress_projects_nothing</h3>
<p>A keypress that moves only this client&#39;s cursor reaches no other client (gotcha 3).</p>
<h3 id="every-model-command-names-an-effect-that-declares-a-model-change"><a class="anchor" href="#every-model-command-names-an-effect-that-declares-a-model-change">#</a>every_model_command_names_an_effect_that_declares_a_model_change</h3>
<p>The derivation, stated as a check. This is what &quot;derived, not invented&quot; means operationally (<code>doctrine(derive_the_enum_from_the_effects)</code>). A variant pointed at a <code>ViewOnly</code> effect would be a wire message that changed the document for the client that sent it and for nobody else — <code>Dirty::of</code> would report <code>NOTHING</code>, and no projection would ever correct the others (gotcha 35).</p>
<h4 id="helper-model-app"><a class="anchor" href="#helper-model-app">#</a>Helper — <code>model_app</code></h4>
<p>Not a test — the fixture the three <code>every_model_command</code> walks run against.</p>
<p><code>app()</code> with a cell actually stored, so a surrogate for it resolves through the index rather than only through the layout. The walks over <code>every_model_command</code> run against this: a <code>SetCell</code> whose record cannot be resolved produces <em>no effects</em>, and every one of those tests asserts on the effects, so the fixture has to name something real or they would all fail for a reason that is not theirs.</p>
<h3 id="no-model-command-commits-another-clients-session-local-state"><a class="anchor" href="#no-model-command-commits-another-clients-session-local-state">#</a>no_model_command_commits_another_clients_session_local_state</h3>
<p><a class="tid" href="../tickets.html#improvise-3x3u" title="Resolve the two unaddressed nullary wire variants before concurrency lands">improvise-3x3u</a>: the wire vocabulary is the <em>addressable</em> model effects, not all of them.</p>
<p><code>Sessions::apply</code> routes a <code>Command::Model</code> with no <code>SessionId</code>, which is right exactly when the change is addressed to no one session (gotcha 36). <code>ApplyAndClearDrill</code> and <code>AcceptWizardModel</code> are addressed to precisely one, and it is not the sender: an unaddressed <code>ApplyAndClearDrill</code> commits another client&#39;s private typing (<a class="tid" href="../tickets.html#improvise-tom1" title="Drill pending edits are invisible to remote clients, and there are now three answers to &#39;what text is in this cell&#39;">improvise-tom1</a>) and an unaddressed <code>AcceptWizardModel</code> swaps the document out of a dialogue the sender never opened. Both were demonstrated against <code>Sessions::apply</code> before the variants were removed.</p>
<p>Re-adding either variant means making it <em>addressed</em> — carrying a <code>SessionId</code>, with the staging map moved onto the session — not restoring the nullary form this test forbids.</p>
<p><code>SESSION_LOCAL_EFFECTS</code> is the list of the two effect names.</p>
<h3 id="the-session-local-effects-are-real-model-effects"><a class="anchor" href="#the-session-local-effects-are-real-model-effects">#</a>the_session_local_effects_are_real_model_effects</h3>
<p>The guard above is only worth having while the effects it names still exist and are still model changes — otherwise it would pass by naming nothing (<code>doctrine(check_the_checker)</code>).</p>
<h3 id="the-effect-name-reader-reads-all-three-debug-shapes"><a class="anchor" href="#the-effect-name-reader-reads-all-three-debug-shapes">#</a>the_effect_name_reader_reads_all_three_debug_shapes</h3>
<p>The reader above is itself a check, so it gets the injection too (<code>doctrine(check_the_checker)</code>): one effect of each <code>Debug</code> shape, because the leading identifier is followed by a different delimiter in each — nothing, <code>(</code>, and <code> {</code>.</p>
<h4 id="helper-effect-type-name"><a class="anchor" href="#helper-effect-type-name">#</a>Helper — <code>effect_type_name</code></h4>
<p>Not a test — the reader that test injects against, used by the two guards above.</p>
<p>The type name of an effect, read off the leading identifier of its derived <code>Debug</code>. <code>Effect</code> is a trait object, so <code>std::any::type_name</code> is not reachable through it; every effect derives <code>Debug</code>, and a derived <code>Debug</code> opens with the type&#39;s own name for a unit struct, a tuple struct and a braced struct alike. So the name is already on the value — this only reads it back.</p>
<h3 id="every-model-command-names-the-effect-of-the-same-name"><a class="anchor" href="#every-model-command-names-the-effect-of-the-same-name">#</a>every_model_command_names_the_effect_of_the_same_name</h3>
<p>The mapping is name-for-name by construction, so state that: every <code>ModelCommand</code> variant names the effect that carries its own name.</p>
<p>This is the check that separates an effect from its inverse. <code>every_model_command_names_an_effect_that_declares_a_model_change</code> cannot: <code>HideItem</code> and <code>ShowItem</code>, <code>AddItem</code> and <code>RemoveItem</code>, <code>CreateView</code> and <code>DeleteView</code>, <code>SetCell</code> and <code>ClearCell</code> are each two effects of the <em>same</em> <code>EffectKind</code>, so a swapped pair clears that bar while inverting what the wire message does. And <code>a_model_command_lands_where_its_keypress_lands</code> cannot reach them either — most variants have no single keypress to compare against, so it drives three of twenty-eight.</p>
<p>Proven by injection 2026-09-19: <code>HideItem</code> → <code>effect::ShowItem</code>, <code>CreateView</code> → <code>effect::DeleteView</code> and <code>AddItem</code> → <code>effect::RemoveItem</code> all left <code>cargo test --workspace</code> at 918 passing, 0 failing. This test fails on each.</p>
<p>The name-for-name rule is a design constraint, not a coincidence, and it is what makes the derivation checkable at all (<code>doctrine(derive_the_enum_from_the_effects)</code>): a variant whose effect must be named something else is a variant that stopped being derived, and it should fail here and be argued about rather than pass quietly.</p>
<h3 id="the-enumeration-names-each-variant-once"><a class="anchor" href="#the-enumeration-names-each-variant-once">#</a>the_enumeration_names_each_variant_once</h3>
<p>The <code>every_model_command</code> list covers the enum once. A duplicated entry would leave a variant untested while the count still looked right.</p>
<h4 id="helper-every-model-command"><a class="anchor" href="#helper-every-model-command">#</a>Helper — <code>every_model_command</code></h4>
<p>Not a test — the enumeration that test, and the three walks above it, cover.</p>
<p>One value per <code>ModelCommand</code> variant. The enum is the derived set, so a test that walks &quot;the set&quot; has to walk all of it. <code>variant_name</code> is the completeness guard: it matches exhaustively, so a variant added to the protocol fails the build here until it is listed in this vector as well.</p>
<h3 id="a-model-command-lands-where-its-keypress-lands"><a class="anchor" href="#a-model-command-lands-where-its-keypress-lands">#</a>a_model_command_lands_where_its_keypress_lands</h3>
<p>The point of the whole ticket: a model <code>Command</code> applied authoritatively leaves the document exactly where the keypress bound to the same mutation leaves it. That equivalence is what lets the two entry points share one door.</p>
<p>The <code>Dirty</code> is compared too, because a host with connected clients projects off it: a command that produced the right document and the wrong <code>Dirty</code> would leave every other client stale.</p>
<p>Its <code>pivoted()</code> fixture has <strong>two</strong> categories on different axes, because the one-category fixture cannot tell a transpose from a cycle: with only <code>Region</code> on Row, swapping the axes and rotating Row→Column land on the same view, and an equivalence test that cannot distinguish the wrong effect from the right one proves nothing (<code>doctrine(check_the_checker)</code>).</p>
<h3 id="a-stale-wire-model-command-cannot-be-applied"><a class="anchor" href="#a-stale-wire-model-command-cannot-be-applied">#</a>a_stale_wire_model_command_cannot_be_applied</h3>
<p>A wire <code>Command::Model</code> cannot reach the document without its basis being checked (ADR 001, <a class="tid" href="../tickets.html#improvise-ondj" title="[bug] reduce_full/apply_command apply a Command::Model without the basis check">improvise-ondj</a>).</p>
<p>Bug demonstrated 2026-09-19: <code>reduce_full</code> and <code>apply_command</code> — the functions this module&#39;s own doc points every future host at, the browser worker included — took a <code>&amp;Command</code> and matched <code>Command::Model { cmd, .. }</code>, so a batch composed against a document that had since moved landed anyway. The <code>..</code> discarded the basis with no compile error.</p>
<p><strong>The regression guard is the signature, not this test.</strong> Both now take a <code>Checked</code>, whose only constructor runs <code>stale_basis</code>, so the version of this test that reproduced the bug — <code>reduce_full(&amp;mut app, &amp;wire)</code> with a stale <code>wire</code> — no longer compiles, and <code>reduce_full</code> itself no longer exists (<a class="tid" href="../tickets.html#improvise-s17o" title="reduce_full and apply_command&#39;s view-slice arm have no production caller">improvise-s17o</a>: having no caller was the condition that let the defect hide in the first place). What is left to assert is that the constructor refuses, and that refusing leaves the document alone: there is no half-applied batch, because the type is obtained before anything is applied.</p>
<h3 id="a-model-commands-dirty-comes-from-its-effects-kind"><a class="anchor" href="#a-model-commands-dirty-comes-from-its-effects-kind">#</a>a_model_commands_dirty_comes_from_its_effects_kind</h3>
<p>The <code>Dirty</code> a model command reports is the one its effect declares, not a second table kept alongside the enum (gotcha 35).</p>
<h3 id="a-wire-model-command-marks-the-document-dirty"><a class="anchor" href="#a-wire-model-command-marks-the-document-dirty">#</a>a_wire_model_command_marks_the_document_dirty</h3>
<p><a class="tid" href="../tickets.html#improvise-gv9r" title="[bug] A wire model command never marks the document dirty">improvise-gv9r</a>. <strong>The bug</strong>: a client&#39;s edit left the authoritative document reporting <em>clean</em>, on every transport.</p>
<p><code>MarkDirty</code> used to be a separate effect that the <em>command</em> layer composed alongside the mutation, and <a class="tid" href="../tickets.html#improvise-kmgs" title="Derive the model half of the Command vocabulary from the EffectKind::Model effects">improvise-kmgs</a> derived <code>ModelCommand</code> from the effects <strong>one at a time</strong> — so the wire got <code>SetCell</code> without its companion. A derivation correct per element and wrong for a set. &quot;Save if dirty&quot; was therefore a trigger that never fired, and a host built on it loses every client edit silently; the browser worker found that out the hard way and had to over-approximate off the command variant instead.</p>
<p>The fix is the unrepresentable one: dirtiness is raised by <code>App::apply_effects</code> off the same <code>EffectKind</code> the <code>Dirty</code> and the two basis tiers are read from (gotcha 40), so no path can compose a document change without it. There is nothing left to forget, and <code>MarkDirty</code> is gone rather than left as a second answer.</p>
<p>The three levels are asserted separately because they are three different effects: an effect that is only ever reached by one of them would otherwise pass on a sibling&#39;s evidence. The negative arm — that a <code>ViewOnly</code> effect leaves the document clean — is <code>a_view_only_batch_leaves_the_document_clean</code> in <code>ui/app.rs</code>, and it has to live there: a view-slice <code>Command</code> never reaches <code>apply_effects</code> at all (<code>apply_command</code> folds it straight into <code>ViewState</code>), so a negative written against this door would pass however unconditional the raise was.</p>
<h3 id="a-wire-view-switch-leaves-the-document-clean"><a class="anchor" href="#a-wire-view-switch-leaves-the-document-clean">#</a>a_wire_view_switch_leaves_the_document_clean</h3>
<p>The inversion of <code>a_wire_view_switch_marks_the_document_dirty</code>, which asserted the pre-<a class="tid" href="../tickets.html#improvise-jytm" title="Split active_view: document default vs per-client authoritative (improvise-g0f)">improvise-jytm</a> behaviour. A switch used to move <code>Workbook::active_view</code>, which persists as the <code>Initial View</code> line, so it really did put the document out of step with the file. It no longer moves any document state, so there is nothing to persist and the browser worker&#39;s persist trigger (<a class="tid" href="../tickets.html#improvise-gv9r" title="[bug] A wire model command never marks the document dirty">improvise-gv9r</a>) must not fire.</p>
<p>The direction matters: the worker asks the document&#39;s <code>dirty</code> flag rather than a table of variants, so a switch that dirtied spuriously would cost an OPFS rewrite of the whole document per keystroke (gotcha 41). This is the negative arm — the positive one, that a real model command <em>does</em> dirty, is the loop above it.</p>
<p>The consequence to keep in view is a product one, filed as <a class="tid" href="../tickets.html#improvise-azhb" title="Decide who sets a document&#39;s default view now that a switch does not">improvise-azhb</a> rather than decided here: a TUI user who switches view and saves now writes the <code>Initial View</code> the document was loaded with.</p>
<hr />
<h2 id="ui-effect-rs"><a class="anchor" href="#ui-effect-rs">#</a><code>ui/effect.rs</code></h2>
<h3 id="the-document-surface-runs-on-a-non-filesystem-backend"><a class="anchor" href="#the-document-surface-runs-on-a-non-filesystem-backend">#</a>the_document_surface_runs_on_a_non_filesystem_backend</h3>
<p><a class="tid" href="../tickets.html#improvise-fuj" title="Two effects read files directly instead of going through Storage">improvise-fuj</a>: every effect that touches a document goes through <code>App::storage</code>, so the whole save/load/import/export surface works on a backend that is not a filesystem. This is what a browser worker supplies (<a class="tid" href="../tickets.html#improvise-i34" title="OPFS-backed Storage implementation for wasm target">improvise-i34</a>); here <code>MemoryFs</code> stands in for it, and the test would fail if any of these effects reached for <code>std::fs</code>.</p>
<h3 id="view-only-effects-leave-shared-state-alone"><a class="anchor" href="#view-only-effects-leave-shared-state-alone">#</a>view_only_effects_leave_shared_state_alone</h3>
<p><a class="tid" href="../tickets.html#improvise-mae" title="Tag existing effects as model / view / projection-emitting">improvise-mae</a>: a declared <code>kind()</code> is only worth having if it matches what the effect actually does. Rather than restate each declaration, this applies the effect and checks the shared state it claims not to touch is genuinely unchanged — a <code>ViewOnly</code> effect that quietly wrote a cell would never reach connected clients.</p>
<p>No exemptions: since <a class="tid" href="../tickets.html#improvise-8uz" title="Cursor and scroll are per-client state stored in the shared View">improvise-8uz</a> moved the cursor out of <code>View</code>, <code>SetSelected</code>/<code>SetRowOffset</code>/<code>SetColOffset</code> touch nothing shared, so every effect in the list must clear the same bar.</p>
<h3 id="structural-effects-declare-their-kind"><a class="anchor" href="#structural-effects-declare-their-kind">#</a>structural_effects_declare_their_kind</h3>
<p>The structural effects, each pinned to the kind it must declare.</p>
<p>The bug this guards: <code>AddCategory</code> and <code>RemoveCategory</code> declared <code>CellData</code>, but both go through <code>Workbook</code>, whose <code>on_category_added</code> / <code>on_category_removed</code> rewrite <strong>every</strong> view&#39;s axis map. That is a re-layout, so a client told only &quot;cell data changed&quot; would keep rendering the old grid shape. The two &quot;actually change&quot; tests beside it cannot see this — a category change moves model data <em>and</em> view config, so it passes either list. Only pinning the declaration does.</p>
<h3 id="add-category-past-the-limit-reports-the-failure"><a class="anchor" href="#add-category-past-the-limit-reports-the-failure">#</a>add_category_past_the_limit_reports_the_failure</h3>
<p>Bug (<a class="tid" href="../tickets.html#improvise-djn" title="AddCategory effect silently swallows failures">improvise-djn</a>): <code>AddCategory</code> discarded the <code>Result</code> from <code>Workbook::add_category</code>, so pressing <code>n</code> in the category panel with the 12-regular-category limit already reached reported the optimistic <code>Added category &quot;X&quot;</code> its command had queued and added nothing. The effect runs last precisely so its failure can overwrite that message (<a class="tid" href="../tickets.html#improvise-6t3" title="Unify Commit{Formula,CategoryAdd,ItemAdd} into CommitBuffer(kind)">improvise-6t3</a>) — it just never wrote one.</p>
<h3 id="add-formula-adds-target-item-to-category"><a class="anchor" href="#add-formula-adds-target-item-to-category">#</a>add_formula_adds_target_item_to_category</h3>
<p>Regression: <code>AddFormula</code> must add the target item to the target category so the grid layout includes cells for it. Without this, a formula like <code>Margin = Profit / Revenue</code> would be registered but invisible in the grid.</p>
<h3 id="abort-chain-short-circuits-apply-effects"><a class="anchor" href="#abort-chain-short-circuits-apply-effects">#</a>abort_chain_short_circuits_apply_effects</h3>
<p><code>AbortChain</code> must cause subsequent effects in the same <code>apply_effects</code> batch to be skipped, and the flag must reset so the next dispatch starts clean.</p>
<h3 id="save-without-file-path-aborts-the-chain"><a class="anchor" href="#save-without-file-path-aborts-the-chain">#</a>save_without_file_path_aborts_the_chain</h3>
<p>BUG (<a class="tid" href="../tickets.html#improvise-8tk" title="ZZ / :wq quits even when the save fails, silently discarding the model">improvise-8tk</a>): <code>Save</code> reported failure only through the status bar, so the <code>Quit</code> that <code>wq</code>/<code>ZZ</code> chains after it landed anyway and the whole session was discarded without the user seeing the error. A failed save must abort the rest of the batch.</p>
<h3 id="failed-save-as-aborts-the-chain"><a class="anchor" href="#failed-save-as-aborts-the-chain">#</a>failed_save_as_aborts_the_chain</h3>
<p>BUG (<a class="tid" href="../tickets.html#improvise-lae" title="ZZ / :wq with no file path should prompt for a path instead of only erroring">improvise-lae</a>, found reviewing it): <a class="tid" href="../tickets.html#improvise-8tk" title="ZZ / :wq quits even when the save fails, silently discarding the model">improvise-8tk</a> fixed <strong>one arm</strong> of a rule with two. <code>Save</code> aborts the batch when the write fails; <code>SaveAs</code> reported the error to the status bar and let the batch continue, which is the same silent discard 8tk closed — the shape <code>doctrine(one_arm_of_a_symmetric_rule_is_evidence_about_that_arm_only)</code> names, and the three tests above it are evidence about <code>Save</code> only.</p>
<p>It is reachable today: the <code>:</code> line composes with <code> . </code>, so <code>:w /bad/path . q</code> builds exactly this batch, and <code>ExecuteCommand</code> concatenates the two commands&#39; effects into one. <a class="tid" href="../tickets.html#improvise-lae" title="ZZ / :wq with no file path should prompt for a path instead of only erroring">improvise-lae</a>&#39;s save prompt also commits to <code>SaveAs</code>, so the arm was about to carry more traffic.</p>
<p>Note what the abort does and does not buy the prompt: the typed path survives for the user to correct, but the message does not reach the screen, because <code>draw_bottom_bar</code> draws the minibuffer instead of the status line while a prompt is open. That is pre-existing and reported separately; the data-loss half is what this fixes.</p>
<h3 id="successful-save-as-lets-the-chain-continue"><a class="anchor" href="#successful-save-as-lets-the-chain-continue">#</a>successful_save_as_lets_the_chain_continue</h3>
<p>The positive arm: a <code>SaveAs</code> that succeeds must not abort, or every <code>:w &lt;path&gt;</code> would swallow whatever followed it. Without this, &quot;always abort&quot; passes <code>failed_save_as_aborts_the_chain</code>.</p>
<h3 id="enter-edit-at-cursor-uses-target-mode-not-app-mode"><a class="anchor" href="#enter-edit-at-cursor-uses-target-mode-not-app-mode">#</a>enter_edit_at_cursor_uses_target_mode_not_app_mode</h3>
<p><code>EnterEditAtCursor</code> must use its <code>target_mode</code> field, <em>not</em> whatever <code>app.view_state.mode</code> happens to be when applied. The previous implementation branched on <code>app.view_state.mode.is_records()</code>; the parameterized version trusts the caller (keymap or composing command).</p>
<h3 id="enter-edit-at-cursor-reads-post-batch-layout"><a class="anchor" href="#enter-edit-at-cursor-reads-post-batch-layout">#</a>enter_edit_at_cursor_reads_post_batch_layout</h3>
<p><a class="tid" href="../tickets.html#improvise-ehz" title="EnterEditAtCursor effect calls rebuild_layout and builds a CmdContext inside apply">improvise-ehz</a>: the edit buffer must be loaded from layout state that already reflects earlier effects in the same batch. <code>apply_effects</code> only rebuilds <em>after</em> the batch, so a preceding <code>RebuildLayout</code> is what makes the cursor resolve to the post-mutation cell. Removing the row-0 item slides a different cell under the cursor; the buffer must show that cell&#39;s value, not the pre-batch one.</p>
<h3 id="set-buffer-does-not-special-case-the-search-name"><a class="anchor" href="#set-buffer-does-not-special-case-the-search-name">#</a>set_buffer_does_not_special_case_the_search_name</h3>
<p><code>SetBuffer</code> no longer treats &quot;search&quot; as an alias for the search query (<a class="tid" href="../tickets.html#improvise-gdl" title="SetBuffer effect special-cases the &quot;search&quot; buffer name">improvise-gdl</a>): it is a plain buffer write whatever the name says, and the query is reached through <code>SetSearchQuery</code>. Commands pick between the two in <code>cmd::core::write_buffer</code>.</p>
<h3 id="apply-and-clear-drill-coord-rename-moves-every-measure-in-the-row"><a class="anchor" href="#apply-and-clear-drill-coord-rename-moves-every-measure-in-the-row">#</a>apply_and_clear_drill_coord_rename_moves_every_measure_in_the_row</h3>
<p><a class="tid" href="../tickets.html#improvise-c5v" title="Improve records view data entry by making each measure a normal value column (wide format instead of synthetic Value column)">improvise-c5v</a>: in wide format a records row groups one cell per measure, so renaming a coordinate has to move every one of them. The long-format code moved only the cell under the cursor, which would now split the row in half.</p>
<h3 id="import-headless-csv-short-rows-surface-warning-in-status"><a class="anchor" href="#import-headless-csv-short-rows-surface-warning-in-status">#</a>import_headless_csv_short_rows_surface_warning_in_status</h3>
<p>BUG (<a class="tid" href="../tickets.html#improvise-k8i" title="CSV import silently drops columns when row widths mismatch">improvise-k8i</a>): <code>ImportJsonHeadless</code> parsed CSVs with <code>parse_csv</code>, which sends short-row warnings to stderr — invisible inside the TUI. Short rows must surface in the status message, naming the affected column, never silently succeed.</p>
<h4 id="note-the-drill-tests-drive-apply-effects-not-apply"><a class="anchor" href="#note-the-drill-tests-drive-apply-effects-not-apply">#</a>Note — the drill tests drive <code>apply_effects</code>, not <code>apply</code></h4>
<p>Several <code>ApplyAndClearDrill</code> tests call <code>app.apply_effects(vec![...])</code> rather than <code>effect.apply(&amp;mut app)</code>. That is deliberate: the dirty flag is raised off the <code>EffectKind</code> now (<a class="tid" href="../tickets.html#improvise-gv9r" title="[bug] A wire model command never marks the document dirty">improvise-gv9r</a>, gotcha 45), so driving the effect directly would assert a flag nothing in production sets.</p>
<h3 id="every-file-writing-effect-aborts-the-batch-on-failure"><a class="anchor" href="#every-file-writing-effect-aborts-the-batch-on-failure">#</a>every_file_writing_effect_aborts_the_batch_on_failure</h3>
<p><a class="tid" href="../tickets.html#improvise-z05r" title="CommitExport does not bail on an empty path, and ExportCsv does not abort the batch">improvise-z05r</a>, the <strong>third</strong> instance of one rule: <a class="tid" href="../tickets.html#improvise-8tk" title="ZZ / :wq quits even when the save fails, silently discarding the model">improvise-8tk</a> fixed <code>Save</code> and not <code>SaveAs</code>; PR #27 found <code>SaveAs</code>; <code>ExportCsv</code> was the arm still missing, so a <code>Binding::Sequence</code> or a <code>:</code> line composing an export with anything after it continued past a failed write.</p>
<p>The fix is structural rather than a third patch: all three now report through one <code>report_file_write(app, outcome)</code>, which writes the status message either way and sets <code>abort_effects</code> on <code>Err</code>. That is not sufficient by itself — a fourth writer could still be written without calling it — so the test names the three, and a fourth has to come here and be named. Naming is the mechanism; the shared function is what makes the naming cheap to honour.</p>
<p>Each case drives <code>App::apply_effects</code> with <code>[writer, ChangeMode(Quit)]</code> against a path under a directory that does not exist, and asserts both <code>last_batch_aborted()</code> and that the trailing mode change never landed.</p>
<p>Injected fault: drop <code>app.abort_effects = true</code> from <code>report_file_write</code>. Fails by name, along with the four older abort tests — which is the point: they and this one now share one mechanism.</p>
<p>Injected fault, narrower: route <code>ExportCsv</code> around the reporter and write its status directly. Only this test fails, naming <code>ExportCsv</code>, with 460 passing — the exact state the ticket describes.</p>
<h3 id="a-batch-that-writes-nothing-leaves-the-document-clean"><a class="anchor" href="#a-batch-that-writes-nothing-leaves-the-document-clean">#</a>a_batch_that_writes_nothing_leaves_the_document_clean</h3>
<p><a class="tid" href="../tickets.html#improvise-748f" title="A drill exited with no edits, and view-back, mark the document dirty">improvise-748f</a>. <code>ViewNavigate { forward: false }</code> emits <code>ApplyAndClearDrill</code> unconditionally, and that effect returns immediately when <code>drill_state</code> is <code>None</code>. It declares <code>Model(CellData)</code> regardless — <code>Effect::kind</code> takes no <code>App</code>, so the classification cannot be state-dependent — so <code>apply_effects</code> raised <code>model_state.dirty</code> on a document nothing had touched. Press view-back, press <code>:q</code>, and the quit is refused; autosave arms thirty seconds later.</p>
<p>The older test here, <code>start_drill_and_apply_clear_drill_with_no_edits</code>, pinned the wrong behaviour in an assertion message arguing it was the same over-approximation a failed <code>LoadModel</code> gets. It is not: a succeeding <code>LoadModel</code> leaves a document that really does differ from the file its path names, and that remains declared. This was a wrong answer to no divergence at all.</p>
<h3 id="a-cell-data-effect-no-raw-cell-records-still-dirties-the-document"><a class="anchor" href="#a-cell-data-effect-no-raw-cell-records-still-dirties-the-document">#</a>a_cell_data_effect_no_raw_cell_records_still_dirties_the_document</h3>
<p>The guard against the cure <a class="tid" href="../tickets.html#improvise-748f" title="A drill exited with no edits, and view-back, mark the document dirty">improvise-748f</a> <strong>proposed</strong>, which is unsound. The ticket suggested raising <code>dirty</code> off <code>bump_changed_records</code>&#39; per-record fingerprint of the raw cells, on the reading that <code>CellData</code> means cells. Five of the ten <code>CellData</code> effects change the document without writing a raw cell: <code>AddItem</code>, <code>AddItemInGroup</code> and <code>SortData</code> (which reorders — and <code>.improv</code> carries the order), and <code>AddFormula</code> and <code>RemoveFormula</code>, whose results live in <code>Model::formula_cache</code> and not in the store. Observing raw cells would have left every one of them reading clean, which is a <em>lost edit</em> — the opposite direction from the spurious prompt it was fixing.</p>
<p>So the observation is <code>document_content</code> — <code>document_config</code> plus the cells in iteration order and the formulas — and <strong>only the <code>CellData</code> arm reads it</strong>. The <code>LayoutGeometry</code> arm keeps the narrow <code>document_config</code>. A first version pointed both arms at the wide fingerprint, and review benchmarked what that costs at 18,000 cells in release over 200 batches after warm-up: one <code>SetCell</code> batch 8.07 ms → 10.21 ms (+27%, a constant factor on a path that was already O(cells) through <code>record_fingerprints</code>), but one <code>SetNumberFormat</code> batch <strong>78 µs → 2.63 ms, 33×</strong> — an <em>asymptotic</em> change from O(config) to O(cells + config) on the held-key path that <code>[</code>/<code>]</code>, axis cycle, hide/show, group toggle, transpose and prune toggle all take. ~380/sec where they had been ~12,800/sec. The split costs no correctness because all five blind effects are <code>CellData</code>; that every <code>LayoutGeometry</code> effect really is visible to the narrow fingerprint is asserted separately, and not left as a reading.</p>
<p><code>WholeWorkbook</code> stays <em>declared</em>, and that is the remaining over-report: a <code>LoadModel</code> whose incoming content happens to be identical still marks the document dirty, and a failed one does too. Both cost one redundant write.</p>
<p>The <code>SortData</code> case needs two cells inserted out of sorted order, or it reorders nothing and its case asserts the opposite of what it names. The <code>AddFormula</code> case removes the fixture&#39;s formula first, or adding it is a no-op.</p>
<p>Injected fault: narrow <code>document_content</code> back to the configuration it hashed before. Fails naming <code>SortData</code>, with five other dirty-flag tests.</p>
<h3 id="every-layout-geometry-effect-is-visible-to-the-narrow-fingerprint"><a class="anchor" href="#every-layout-geometry-effect-is-visible-to-the-narrow-fingerprint">#</a>every_layout_geometry_effect_is_visible_to_the_narrow_fingerprint</h3>
<p>The guard that makes the two-fingerprint split safe rather than a trap. Splitting <code>CellData</code> (wide) from <code>LayoutGeometry</code> (narrow) is only sound while every effect wearing <code>LayoutGeometry</code> writes something <code>document_config</code> hashes; an effect it cannot see would leave the document reading clean <strong>forever</strong>, which is the same lost-edit failure the wide fingerprint exists to prevent, arriving by the other door.</p>
<p>The thirteen are enumerated by brace-matching the <code>impl Effect</code> blocks, not by grepping — the naive grep reports fourteen, picking up <code>HelpPageSet</code>, which is <code>ViewOnly</code> and is simply the last <code>pub struct</code> before <code>#[cfg(test)]</code> (gotcha 38 names this exact artefact). Each case asserts its effect really declares <code>Model(LayoutGeometry)</code> before asserting anything else, so a reclassified effect fails here rather than silently leaving the list unrepresentative.</p>
<p><strong>Four</strong> cases carry a <code>prepare</code> step run <strong>directly against the workbook</strong> and not through an effect, because the interesting state is the precondition. What makes such a case honest is the <strong>no-op-fails</strong> property: without its precondition the effect writes nothing, an unwritten document leaves the fingerprint unmoved, and the case therefore <em>fails</em> rather than passing vacuously.</p>
<p>Measured, by deleting each <code>prepare</code> in turn, <strong>two</strong> of the four are load-bearing in exactly that way and two are not:</p>
<ul>
<li><code>DeleteView</code> needs a second view; without it, <code>dirty</code> reads false and the case fails.</li>
<li><code>ShowItem</code> needs a hidden item; without it, <code>dirty</code> reads false and the case fails.</li>
<li><code>ToggleGroup</code> is <strong>not</strong> load-bearing here. <code>ToggleGroup::apply</code> calls <code>toggle_group_collapse</code> unconditionally — its membership check gates only <code>reset_cursor</code> — so <code>collapsed_groups</code> moves whether or not the group is real, and the case passes with the <code>prepare</code> deleted. The step documents the realistic input; it is the <em>cursor</em> arm, tested elsewhere, that needs a real group.</li>
<li><code>TransposeAxes</code> is <strong>not</strong> load-bearing either, and its step is literally a no-op: <code>View::on_category_added</code> already assigns the first regular category <code>Row</code> and the second <code>Column</code>, so the fixture arrives with <code>Type</code>/<code>Month</code> differing. The step states the precondition rather than establishing it, and the case passes without it.</li>
</ul>
<p><code>dirty</code> is cleared <strong>after</strong> <code>prepare</code> and before the effect. That clear is defensive hygiene for a <em>future</em> <code>prepare</code> that goes through <code>apply_effects</code>, and it is not what makes any case honest today: <code>dirty</code> is raised <strong>only</strong> inside <code>App::apply_effects</code>, and no <code>prepare</code> in this test runs an effect, so no preparation can set it. Demonstrated by removing the clear <strong>and</strong> blinding <code>document_config</code> to <code>0</code> together: all thirteen cases still read <code>dirty == false</code>. An earlier version of this entry claimed the opposite — that without the clear &quot;all four cases pass against any fingerprint&quot; — and measurement contradicts it.</p>
<p>Injected fault: <code>document_config</code> returns <code>0</code>. Fails, with five other dirty-flag tests across three files. Injected fault, narrower and the one that proves the test is pointed at the right thing: drop <code>view.number_format</code> from <code>document_config</code>. Fails naming <strong><code>SetNumberFormat</code></strong>, with only one other test failing anywhere.</p>
<hr />
<h2 id="ui-app-rs"><a class="anchor" href="#ui-app-rs">#</a><code>ui/app.rs</code></h2>
<h3 id="model-state-and-view-state-are-constructible"><a class="anchor" href="#model-state-and-view-state-are-constructible">#</a>model_state_and_view_state_are_constructible</h3>
<p><a class="tid" href="../tickets.html#improvise-3vr" title="vb4 step 1: scaffold ModelState and ViewState types">improvise-3vr</a>: <code>ModelState</code> and <code>ViewState</code> are the named slices of <code>App</code> state introduced by the vb4 refactor. Step 1 only requires that the types exist and are constructible; subsequent steps move fields in.</p>
<h4 id="note-the-viewstate-send-static-assertion"><a class="anchor" href="#note-the-viewstate-send-static-assertion">#</a>Note — the <code>ViewState: Send</code> static assertion</h4>
<p><a class="tid" href="../tickets.html#improvise-h0b" title="Evict wizard and drill_state from ViewState (bootstrap-snapshot hygiene)">improvise-h0b</a>: <code>ViewState</code> is the portable slice — the one thing serialized into the Snapshot a newly-connected client receives (<a class="tid" href="../tickets.html#improvise-q08" title="improvise-ws-server binary (tokio + tungstenite session wrapper)">improvise-q08</a>). <code>Send</code> is the cheapest compile-time proxy for &quot;portable&quot;: a field that cannot cross a thread boundary certainly cannot cross a serialization one. Before h0b this failed to compile, because <code>drill_state: Option&lt;DrillState&gt;</code> holds an <code>Rc&lt;Vec&lt;RecordRow&gt;&gt;</code>. Session-local state lives on <code>App</code> instead. It is a <code>const _: fn()</code> in the test module, not a named test, so it has no heading of its own.</p>
<h3 id="app-view-state-owns-ui-session-fields"><a class="anchor" href="#app-view-state-owns-ui-session-fields">#</a>app_view_state_owns_ui_session_fields</h3>
<p><a class="tid" href="../tickets.html#improvise-ew0" title="vb4 step 3: move UI session fields into ViewState">improvise-ew0</a>: <code>ViewState</code> owns the UI session slice — mode, status, search, panels, navigation, drill, yanked, buffers. <code>App</code> accesses them through <code>view_state</code>.</p>
<h3 id="view-state-survives-a-json-round-trip"><a class="anchor" href="#view-state-survives-a-json-round-trip">#</a>view_state_survives_a_json_round_trip</h3>
<p><a class="tid" href="../tickets.html#improvise-a2x" title="Derive Serialize/Deserialize for the ViewState field types">improvise-a2x</a>: <code>ViewState</code> is what a newly-connected client receives as a Snapshot (<a class="tid" href="../tickets.html#improvise-q08" title="improvise-ws-server binary (tokio + tungstenite session wrapper)">improvise-q08</a>), so it has to survive a round trip through the wire format. Populated with non-default values in every field family — a mode carrying a <code>MinibufferConfig</code>, a navigation frame, a yanked cell, a keyed buffer, an armed prefix — because a roundtrip of <code>ViewState::default()</code> would pass while proving nothing.</p>
<h3 id="the-documents-default-view-outlives-this-clients-switches"><a class="anchor" href="#the-documents-default-view-outlives-this-clients-switches">#</a>the_documents_default_view_outlives_this_clients_switches</h3>
<p>The replacement for <code>the_two_active_view_names_never_diverge</code>, and it is a replacement rather than a deletion on purpose: <a class="tid" href="../tickets.html#improvise-jytm" title="Split active_view: document default vs per-client authoritative (improvise-g0f)">improvise-jytm</a> makes the two copies diverge deliberately, and a deliberate split with no guard is how one silently regresses into the other.</p>
<p>What it pins, all four of which have a silent failure mode. A switch moves the client&#39;s copy and <strong>not</strong> the document&#39;s default. <code>App::view()</code> — what <code>rebuild_layout</code> and <code>cmd_context</code> read — follows the client, so the split is observable in the layout and not merely in a string. A switch to a view the document does not have moves nothing, because the slice keys <code>ViewState::cursors</code> and <code>grid_in</code> indexes the workbook by it. And <code>install_workbook</code> still seats this client on the incoming document&#39;s default and clears the cursor map, since the outgoing document&#39;s view names mean nothing in the new one (gotcha 24).</p>
<p>Fault injected 2026-09-20: <code>App::switch_to_view</code> calling <code>workbook.switch_view(name)</code> again fails the second assertion.</p>
<h3 id="moving-the-cursor-does-not-touch-the-shared-workbook"><a class="anchor" href="#moving-the-cursor-does-not-touch-the-shared-workbook">#</a>moving_the_cursor_does_not_touch_the_shared_workbook</h3>
<p><a class="tid" href="../tickets.html#improvise-8uz" title="Cursor and scroll are per-client state stored in the shared View">improvise-8uz</a>: moving the cursor must leave the shared document untouched, so that one client scrolling cannot move another&#39;s viewport. Before the move these three effects wrote into <code>model_state.workbook</code>, and this assertion would have failed.</p>
<h3 id="app-owns-the-session-local-fields-not-view-state"><a class="anchor" href="#app-owns-the-session-local-fields-not-view-state">#</a>app_owns_the_session_local_fields_not_view_state</h3>
<p><a class="tid" href="../tickets.html#improvise-h0b" title="Evict wizard and drill_state from ViewState (bootstrap-snapshot hygiene)">improvise-h0b</a>: the two session-local fields moved off <code>ViewState</code> and onto <code>App</code>. Each fails both membership tests: <code>ImportWizard</code> is an <code>improvise-io</code> type (not portable) and a half-open dialog (not snapshot-safe); <code>DrillState</code> holds an <code>Rc</code> (not portable — see the <code>Send</code> assertion) and stages uncommitted edits (not snapshot-safe).</p>
<h3 id="staged-drill-edits-are-unreachable-from-view-state"><a class="anchor" href="#staged-drill-edits-are-unreachable-from-view-state">#</a>staged_drill_edits_are_unreachable_from_view_state</h3>
<p><a class="tid" href="../tickets.html#improvise-h0b" title="Evict wizard and drill_state from ViewState (bootstrap-snapshot hygiene)">improvise-h0b</a>: a fresh client must not inherit another session&#39;s staged, uncommitted drill edits. Those edits are unreachable from <code>ViewState</code>, so a Snapshot built from it cannot carry them.</p>
<h3 id="switching-views-restores-each-views-own-cursor"><a class="anchor" href="#switching-views-restores-each-views-own-cursor">#</a>switching_views_restores_each_views_own_cursor</h3>
<p><a class="tid" href="../tickets.html#improvise-8uz" title="Cursor and scroll are per-client state stored in the shared View">improvise-8uz</a>: the cursor is <strong>per view</strong>. Written as a characterization test against the old <code>View</code>-owned fields and kept verbatim across the move to <code>ViewState::cursors</code>, because a refactor about <em>where</em> state lives must not change <em>what the app does</em>.</p>
<h3 id="the-improv-format-does-not-persist-the-cursor"><a class="anchor" href="#the-improv-format-does-not-persist-the-cursor">#</a>the_improv_format_does_not_persist_the_cursor</h3>
<p><a class="tid" href="../tickets.html#improvise-8uz" title="Cursor and scroll are per-client state stored in the shared View">improvise-8uz</a>: the ticket asserted these fields were &quot;PERSISTED in .improv&quot;. They were not — the grammar has no cursor production and the writer emitted none. That is what made the move free: there was no restore-where-you-left-off behaviour to lose, and no format change to make. The assertion still guards the format going forward.</p>
<h3 id="entering-records-mode-sorts-existing-data"><a class="anchor" href="#entering-records-mode-sorts-existing-data">#</a>entering_records_mode_sorts_existing_data</h3>
<p>Pressing <code>R</code> to enter records mode sorts existing data by <code>CellKey</code>, so display order is deterministic regardless of insertion order.</p>
<p><em>Note:</em> this test&#39;s doc comment carried a stray first line from a neighbouring test (&quot;Regression: pressing <code>o</code> in an empty records view should create the&quot;) that was never finished. It was dropped in the comment-cap wave; the sentence above is what the test actually asserts.</p>
<h3 id="the-new-category-column-creates-a-category-and-its-column"><a class="anchor" href="#the-new-category-column-creates-a-category-and-its-column">#</a>the_new_category_column_creates_a_category_and_its_column</h3>
<p><a class="tid" href="../tickets.html#improvise-2zq" title="Records mode has no inline way to add a category, unlike the + column for measures">improvise-2zq</a> step 2: the <code>+cat</code> column creates a category and its column, without leaving the grid.</p>
<p>Driven through real keys. The assertion that earns its place is the last one: the new column is <strong>usable immediately</strong>, which is the whole point — <code>records_columns</code> filters on <code>kind.is_regular()</code> and never requires items, so a category with no items is a column the moment it exists.</p>
<h3 id="enter-on-plus-cat-in-the-last-row-does-not-reopen-category-creation"><a class="anchor" href="#enter-on-plus-cat-in-the-last-row-does-not-reopen-category-creation">#</a>enter_on_plus_cat_in_the_last_row_does_not_reopen_category_creation</h3>
<p><a class="tid" href="../tickets.html#improvise-2zq" title="Records mode has no inline way to add a category, unlike the + column for measures">improvise-2zq</a>, found by Copilot on PR #27: creating a category from <code>+cat</code> shifted the grid under the advance that followed it.</p>
<p><code>add_category</code> inserts the new coordinate column <strong>immediately before <code>+cat</code></strong>, so every column index from <code>+cat</code> rightwards moves by one. <code>CommitAndAdvance</code> then computed its advance from <code>ctx</code>, whose layout is the pre-commit one (gotcha 23: <code>apply_effects</code> rebuilds only after the whole batch). On the last row <code>Enter</code> wraps to the next column — and the index it wrapped to was <code>+cat</code> <em>again</em> after the rebuild.</p>
<p>So the failure is not &quot;lands one column off&quot;. It <strong>reopens category creation</strong>: a user naming two categories in a row never reaches the column they just made, and gets an unbounded <code>+cat</code> loop instead. The last row is the arm that wraps, so it is the arm tested.</p>
<h3 id="tab-on-plus-cat-does-not-reopen-category-creation"><a class="anchor" href="#tab-on-plus-cat-does-not-reopen-category-creation">#</a>tab_on_plus_cat_does_not_reopen_category_creation</h3>
<p>The same stale index reached <code>Tab</code>, which advances right rather than wrapping. Both directions read the pre-commit layout, so both are covered — one arm of a two-arm rule is the shape <code>doctrine(one_arm_of_a_symmetric_rule_is_evidence_about_that_arm_only)</code> names.</p>
<h3 id="the-new-category-column-creates-the-column-but-writes-no-coordinate"><a class="anchor" href="#the-new-category-column-creates-the-column-but-writes-no-coordinate">#</a>the_new_category_column_creates_the_column_but_writes_no_coordinate</h3>
<p>Committing the <code>+cat</code> name creates the <strong>column</strong> and does not write the coordinate on the row the cursor was on.</p>
<p>The deliberate answer to the ticket&#39;s third question, and the reason is gotcha 1: setting a coordinate moves <em>every</em> measure cell in the row. That is a data migration across N cells, and attaching it to the same <code>Enter</code> that made a schema change — in an app with no undo — is a bigger single keystroke than the measure <code>+</code> asks for. The follow-up edit is one column away.</p>
<h3 id="the-new-category-column-refuses-past-the-limit-where-the-user-can-see-it"><a class="anchor" href="#the-new-category-column-refuses-past-the-limit-where-the-user-can-see-it">#</a>the_new_category_column_refuses_past_the_limit_where_the_user_can_see_it</h3>
<p><code>MAX_CATEGORIES</code> is a reachable refusal on this path, and the refusal has to be <strong>visible</strong>.</p>
<p><code>draw_bottom_bar</code> renders the minibuffer instead of the status line, so a message written while an editor is open is never drawn. The commit path therefore drops out of editing <em>before</em> running the add, and <code>AddCategory</code> aborts the batch on refusal so the trailing <code>EnterEditAtCursor</code> cannot re-open the editor over the message.</p>
<p>The limit itself is checked in exactly one place — <code>Model::add_category</code> — and nothing here restates it; the test fills the document to the limit by asking for categories until it refuses.</p>
<h3 id="naming-a-category-in-records-mode-does-not-eject-the-user"><a class="anchor" href="#naming-a-category-in-records-mode-does-not-eject-the-user">#</a>naming_a_category_in_records_mode_does_not_eject_the_user</h3>
<p><a class="tid" href="../tickets.html#improvise-2zq" title="Records mode has no inline way to add a category, unlike the + column for measures">improvise-2zq</a> step 1: naming a category in records mode used to eject the user from the grid they were standing in.</p>
<p>Records mode is <code>_Index</code> on Row and <code>_Dim</code> on Column, with every regular category on <code>Axis::None</code>. <code>View::on_category_added</code> seated a new regular category on the first axis no <em>regular</em> category held — Row — and <strong>bumped the virtual out of the way</strong> to get there. So <code>N</code>, a name, <code>Enter</code> moved <code>_Index</code> off Row, records detection stopped matching, and the grid silently became a pivot.</p>
<p>Driven through the real keys rather than through <code>on_category_added</code>, because that is how the bug was found and it is what makes this evidence rather than a restatement of the fix.</p>
<h3 id="i-and-o-diverge-on-an-empty-records-grid"><a class="anchor" href="#i-and-o-diverge-on-an-empty-records-grid">#</a>i_and_o_diverge_on_an_empty_records_grid</h3>
<p><a class="tid" href="../tickets.html#improvise-6sq" title="Merge EditOrDrill and EnterEditAtCursorCmd into EnterEdit{mode}">improvise-6sq</a>: <code>i</code> and <code>o</code> want opposite behaviour on an empty records grid, which is why <code>edit-or-drill</code> and <code>enter-edit-at-cursor</code> cannot be merged into one command. Entering records mode puts every regular category on <code>Axis::None</code>, so <code>EditOrDrill</code>&#39;s aggregation check is true unless the cursor is on a synthetic record — and on an empty grid there is none, so <code>i</code> reports &quot;cannot drill&quot;. <code>o</code> must still open an edit there (see <code>add_record_row_in_empty_records_view_creates_first_row</code>), so routing it through the same drill check would break it.</p>
<p><a class="tid" href="../tickets.html#improvise-ct5" title="i on an empty records grid reports &#39;cannot drill&#39;, leaking a pivot-mode concept">improvise-ct5</a> changed the <em>message</em> and deliberately not the divergence. The outcome — <code>i</code> opens no editor where there is nothing to edit — is right, and <code>o</code> one key away is the affordance for adding a row. What was wrong is that the message said &quot;cannot drill&quot;, a pivot-mode concept in a mode whose model is &quot;a table I type into&quot;. Making <code>i</code> behave like <code>o</code> here (the ticket&#39;s option (b)) would collapse exactly the divergence <a class="tid" href="../tickets.html#improvise-6sq" title="Merge EditOrDrill and EnterEditAtCursorCmd into EnterEdit{mode}">improvise-6sq</a> exists to record, so the assertion still pins that <code>i</code> does not enter editing; only the wording assertion moved, and it now also names what must <em>not</em> appear.</p>
<h3 id="the-empty-grid-message-is-not-the-records-one-in-pivot-mode"><a class="anchor" href="#the-empty-grid-message-is-not-the-records-one-in-pivot-mode">#</a>the_empty_grid_message_is_not_the_records_one_in_pivot_mode</h3>
<p>The negative arm of <a class="tid" href="../tickets.html#improvise-ct5" title="i on an empty records grid reports &#39;cannot drill&#39;, leaking a pivot-mode concept">improvise-ct5</a>, and the reason the wording is keyed off the mode the keymap supplies rather than reworded once.</p>
<p>Without this, &quot;say the records sentence everywhere&quot; passes <code>i_and_o_diverge_on_an_empty_records_grid</code> — and it would promise <code>o</code>, which adds no row in pivot mode, trading one misleading message for another.</p>
<p>The fixture is an empty pivot grid that still counts as aggregated: <code>Region</code> on Row with no items makes <code>cell_key()</code> <code>None</code>, and <code>Extra</code> cycled to <code>Axis::None</code> is the regular category that makes <code>EditOrDrill</code> take the drill branch.</p>
<h3 id="blank-model-bootstraps-its-first-measure-from-the-new-measure-column"><a class="anchor" href="#blank-model-bootstraps-its-first-measure-from-the-new-measure-column">#</a>blank_model_bootstraps_its_first_measure_from_the_new_measure_column</h3>
<p>A blank model has no categories and no measures, so wide format shows exactly the two creation columns. Typing a name into the new-measure one creates the measure, which gives it a real column to type values into — records mode stays self-sufficient for onboarding (<a class="tid" href="../tickets.html#improvise-c5v" title="Improve records view data entry by making each measure a normal value column (wide format instead of synthetic Value column)">improvise-c5v</a>).</p>
<p><a class="tid" href="../tickets.html#improvise-2zq" title="Records mode has no inline way to add a category, unlike the + column for measures">improvise-2zq</a> widened that: a blank grid now offers <code>+cat</code> beside <code>+</code>, so both halves of a schema can be bootstrapped without leaving the grid. This test still pins the measure half; the category half is <code>the_new_category_column_creates_a_category_and_its_column</code>.</p>
<p>Inside it, the cursor lands at column 0, which in a blank grid is now <code>+cat</code> rather than <code>+</code> (<a class="tid" href="../tickets.html#improvise-2zq" title="Records mode has no inline way to add a category, unlike the + column for measures">improvise-2zq</a>) — a record needs coordinates before it needs measures, so that order is deliberate. <code>o</code> leaves the test editing column 0, so the <code>Esc</code> before moving to the new-measure column is load-bearing.</p>
<h3 id="ctrl-w-prompts-for-a-path-and-a-failed-write-reports-it-where-it-shows"><a class="anchor" href="#ctrl-w-prompts-for-a-path-and-a-failed-write-reports-it-where-it-shows">#</a>ctrl_w_prompts_for_a_path_and_a_failed_write_reports_it_where_it_shows</h3>
<p><a class="tid" href="../tickets.html#improvise-lae" title="ZZ / :wq with no file path should prompt for a path instead of only erroring">improvise-lae</a> end to end: <code>Ctrl-W</code>, a path, <code>Enter</code>.</p>
<p>The failing case is the interesting one, and it pins an <strong>ordering</strong> (found by Copilot on PR #27). <code>SaveAs</code> aborts the batch on a failed write, so whatever <code>CommitSave</code> emits after it is skipped. A first version emitted <code>[SaveAs, ChangeMode(Normal)]</code>, which left the app in <code>SavePrompt</code> — and <code>draw_bottom_bar</code> renders the minibuffer <em>instead of</em> the status line, so the &quot;Save error:&quot; the effect had just set was never drawn. The save appeared to do nothing whatsoever.</p>
<p>Leading with the mode change puts the error in <code>Normal</code>, where the bottom row is the status bar. This test therefore asserts the mode <em>left</em> the prompt, which is the opposite of what it asserted before — deliberately, and it is the only thing keeping the two effects in that order.</p>
<h3 id="every-minibuffer-mode-entered-from-records-returns-to-records-normal"><a class="anchor" href="#every-minibuffer-mode-entered-from-records-returns-to-records-normal">#</a>every_minibuffer_mode_entered_from_records_returns_to_records_normal</h3>
<p>Copilot round 2 reported that <code>Ctrl-W</code> from a records grid returns to <code>Normal</code>, losing the records keymap while the layout is still a records layout. It is right, and it is a <strong>class</strong>: every minibuffer mode reachable from <code>RecordsNormal</code> exited to <code>Normal</code>, because the exit was a constant baked into <code>TextEntry::new(..., &quot;normal&quot;)</code> and into each commit command.</p>
<p>The class <strong>predates</strong> <code>SavePrompt</code>: <code>:</code> and <code>Ctrl-E</code> do exactly the same thing and always have. That is why the fix was not &quot;make <code>SavePrompt</code> remember its origin&quot; — that would leave two of the three arms broken, which is the shape <code>doctrine(one_arm_of_a_symmetric_rule_is_evidence_about_that_arm_only)</code> names.</p>
<p>The binding <em>table</em> is not involved and was checked rather than reasoned about: an <code>IMPROVISE_BINDING_DUMP</code> diff shows <code>RecordsNormal</code> gains <code>Ctrl-W</code> and loses nothing, so <code>with_parent</code> inheritance (gotcha 13) is intact. What was lost is the runtime mode.</p>
<p><a class="tid" href="../tickets.html#improvise-sihr" title="Every minibuffer mode entered from records mode exits to plain Normal while the layout is still records">improvise-sihr</a> fixes it by <strong>observation rather than memory</strong>, which is what took the exit out of the keymap entirely: <code>App::apply_effects</code> calls <code>settle_normal_mode</code> after <code>rebuild_layout</code>, so the mode a batch <em>ends</em> in matches the grid it ends on and nothing carries an entry mode. The ticket proposed the opposite — carrying the entry mode on <code>MinibufferConfig</code> — and that was declined for a concrete reason beyond doctrine: carried generically it would also return <code>CategoryAdd</code> to <code>CategoryPanel</code>, a behaviour change nobody asked for, while carried for only three modes it is the one-arm shape again.</p>
<p>This test still drives <strong>both exits</strong> — Esc and Enter — where the original drove only Esc, because they reach the mode through different effects (<code>ChangeMode</code> and <code>ReturnToNormalUnlessMoved</code>) even though one settle now covers both.</p>
<p><strong>The first version of the fix resolved per-effect and was wrong in a new direction</strong>, which the adversarial review of PR #91 caught. Set at the moment the mode is written, the resolution reads the batch&#39;s <em>opening</em> layout (gotcha 23) — so a batch that <strong>crosses</strong> settled against the grid it started on. See <code>a_batch_that_leaves_records_and_then_sets_normal_lands_in_normal</code>.</p>
<p>Injected fault: <code>settle_normal_mode()</code>&#39;s call site removed. Exactly three tests fail across the whole workspace — this one, the <code>:</code>-line sibling and the crossing test — which is the per-edit coverage question answered by there being one edit.</p>
<h3 id="a-colon-line-run-from-records-settles-back-into-records-normal"><a class="anchor" href="#a-colon-line-run-from-records-settles-back-into-records-normal">#</a>a_colon_line_run_from_records_settles_back_into_records_normal</h3>
<p>The sibling above cannot see <code>ReturnToNormalUnlessMoved</code>, and that was found by injection rather than by reading: forcing <em>only</em> that effect back to <code>AppMode::Normal</code> left the sibling green. An <strong>empty</strong> <code>:</code> line — which is what pressing Enter straight after <code>:</code> gives — takes <code>ExecuteCommand</code>&#39;s <code>raw.is_empty()</code> branch and settles through <code>ChangeMode</code>, so the effect that settles every line that actually runs was untested.</p>
<p>So this test types a line. <code>page-next</code> rather than <code>yank</code>, because the <em>script</em> parser requires coordinates for <code>yank</code> and an unparseable line takes the error branch — which settles through <code>ChangeMode</code> too, and would have made this test a second copy of its sibling while looking like a new case. The setup assertion that <code>parse_line_with</code> accepts the line is there for exactly that: a future rename of the command would otherwise turn this test vacuous silently.</p>
<p>Injected fault (against the per-effect version of the fix): <code>ReturnToNormalUnlessMoved</code> forced to <code>AppMode::Normal</code>. This test failed, <code>left: Normal right: RecordsNormal</code>, while the sibling stayed green — which is the point, and is how this test came to exist. Against the shipped end-of-batch settle the discriminating injection is removing <code>settle_normal_mode()</code>&#39;s call site, which fails this test with the same values.</p>
<h3 id="a-batch-that-leaves-records-and-then-sets-normal-lands-in-normal"><a class="anchor" href="#a-batch-that-leaves-records-and-then-sets-normal-lands-in-normal">#</a>a_batch_that_leaves_records_and_then_sets_normal_lands_in_normal</h3>
<p><strong>New wrong state introduced by PR #91&#39;s first attempt</strong>, found by the adversarial review and verified by reverting only <code>ui/app.rs</code> and <code>ui/effect.rs</code> to base and re-running the identical probe:</p>
<table><thead><tr><th><code>:toggle-records-mode . enter-mode normal</code></th><th>base</th><th>#91 round 1</th></tr></thead><tbody><tr><td>from a pivot grid</td><td><code>Normal</code> (the sihr bug)</td><td><code>Normal</code> still</td></tr><tr><td><strong>from a records grid</strong></td><td><code>Normal</code> (correct)</td><td><strong><code>RecordsNormal</code></strong></td></tr></tbody></table>
<p>The cause is gotcha 23: resolving at the moment the mode is <em>written</em> reads the batch&#39;s <strong>opening</strong> layout, so a batch that crosses settles against the grid it started on. <code>:toggle-records-mode</code> is the case the round-1 prose offered as proof that the staleness was a <em>property</em> — and it is not, because <code>ToggleRecordsMode</code> emits <code>change_mode(RecordsNormal)</code> explicitly on entry and <code>ViewBack</code> restores the pushed mode on exit, so the resolution&#39;s answer is overwritten in both directions and the case exercises nothing. That is a careful comment standing in for a test, in the PR that recorded <code>doctrine(an_injection_that_does_not_fail_names_an_untested_path)</code>.</p>
<p>The fix moves the resolution to <strong>one</strong> place, <code>settle_normal_mode</code> after <code>rebuild_layout</code>, where there is no crossing case left to get wrong.</p>
<p>This test drives <strong>both directions</strong> deliberately. One direction alone is satisfied by a settle that only ever answers one way, and the pivot→records direction was <em>also</em> wrong in round 1 for the mirror-image reason.</p>
<h3 id="a-wizard-abandoned-over-a-records-grid-returns-to-records-normal"><a class="anchor" href="#a-wizard-abandoned-over-a-records-grid-returns-to-records-normal">#</a>a_wizard_abandoned_over_a_records_grid_returns_to_records_normal</h3>
<p><code>CloseWizard</code> writes <code>view_state.mode</code> directly rather than through an effect that could resolve it, so under round 1&#39;s per-effect design it was a <strong>third writer</strong> needing its own edit — and reverting that edit alone left the whole workspace green, which the review filed. The end-of-batch settle removes the per-edit question entirely (there is one edit), and this test covers the path the review named: the wizard abandoned over a records grid.</p>
<p>It drives <code>CloseWizard</code> as an effect rather than typing <code>:import</code>, because the wizard needs a real CSV and the contract under test is one batch&#39;s settling, not the import pipeline.</p>
<h3 id="a-failed-colon-write-lands-in-normal-where-its-error-is-drawn"><a class="anchor" href="#a-failed-colon-write-lands-in-normal-where-its-error-is-drawn">#</a>a_failed_colon_write_lands_in_normal_where_its_error_is_drawn</h3>
<p>A failing <code>:</code> command must not strand the user in <code>CommandMode</code>.</p>
<p>Found by Copilot on PR #27, and it is the sharper form of gotcha 21. The abort is <em>supposed</em> to stop the chain — <code>:w /bad . q</code> must not quit. What it must not also stop is the <strong>bookkeeping</strong>: the mode restoration that <code>ExecuteCommand</code> appends. Aborting before that ran left the app in <code>CommandMode</code> with the stale command line still drawn, and <code>draw_bottom_bar</code> renders the minibuffer <em>instead of</em> the status line — so the &quot;Save error:&quot; set one effect earlier was invisible. A failed save looked exactly like a key that did nothing.</p>
<p>The answer is <strong>ordering, not classification</strong>: a batch puts its tidy-up where an abort cannot reach it, which is in front. No effect needs a new &quot;runs anyway&quot; flag, and the rule generalises to any command composing bookkeeping with something that can fail.</p>
<p>Both arms are here because they fail in different places: no path at all is <code>Save</code>&#39;s guard (aborting since <a class="tid" href="../tickets.html#improvise-8tk" title="ZZ / :wq quits even when the save fails, silently discarding the model">improvise-8tk</a>, so this arm was broken before that branch), and a bad path is <code>SaveAs</code>&#39;s write (aborting since f0f400d).</p>
<h3 id="a-failed-colon-write-still-refuses-to-quit-after-it"><a class="anchor" href="#a-failed-colon-write-still-refuses-to-quit-after-it">#</a>a_failed_colon_write_still_refuses_to_quit_after_it</h3>
<p>The other half of the same rule: the abort must still stop the <em>chain</em>. <code>:w &lt;bad&gt; . q</code> is <a class="tid" href="../tickets.html#improvise-8tk" title="ZZ / :wq quits even when the save fails, silently discarding the model">improvise-8tk</a>&#39;s data loss spelled on one <code>:</code> line, and moving the mode restoration to the front must not have bought the fix above at the price of that one.</p>
<h3 id="a-sequence-whose-first-step-aborts-does-not-run-the-second"><a class="anchor" href="#a-sequence-whose-first-step-aborts-does-not-run-the-second">#</a>a_sequence_whose_first_step_aborts_does_not_run_the_second</h3>
<p>Gotcha 21 claims a failed <code>Save</code> aborts across <code>Binding::Sequence</code> steps as well as within one command&#39;s effect list. Nothing tested it: no shipped <code>Sequence</code> has a first step that can abort, and <code>ZZ</code> is a <em>prefix</em> resolving to the single <code>wq</code> command, so <code>prefix_z_then_z_reaches_save_and_quit</code> exercises the within-a-command case only. The claim was structurally true and unguarded.</p>
<p>It is true because <code>Keymap::dispatch</code> flattens a <code>Sequence</code> into ONE effect vec and <code>App::apply_effects</code> runs that as one batch, breaking on <code>abort_effects</code>. It is <em>load-bearing</em>, because it is the reason a <code>[w, force-quit]</code> Sequence would be safe — the argument the grooming note unblocked on.</p>
<p>And it is exactly what <a class="tid" href="../tickets.html#improvise-0hb" title="Make Sequence bindings transactional at command level">improvise-0hb</a> would remove. Command-level transactionality means applying effects <em>between</em> steps, and <code>apply_effects</code> resets <code>abort_effects</code> at entry and again on abort, so step 2 would be dispatched into a fresh batch with the flag cleared — the <code>Quit</code> after a failed save would land, silently reopening the <a class="tid" href="../tickets.html#improvise-8tk" title="ZZ / :wq quits even when the save fails, silently discarding the model">improvise-8tk</a> P1 data loss. This is the guard that would catch it.</p>
<h3 id="unmatched-key-after-prefix-is-swallowed"><a class="anchor" href="#unmatched-key-after-prefix-is-swallowed">#</a>unmatched_key_after_prefix_is_swallowed</h3>
<p>A prefix is consumed whether or not the next key matches, so a stray key after <code>g</code> does nothing rather than being treated as a fresh normal-mode binding.</p>
<h3 id="renaming-a-coordinate-moves-every-measure-in-the-row"><a class="anchor" href="#renaming-a-coordinate-moves-every-measure-in-the-row">#</a>renaming_a_coordinate_moves_every_measure_in_the_row</h3>
<p><a class="tid" href="../tickets.html#improvise-c5v" title="Improve records view data entry by making each measure a normal value column (wide format instead of synthetic Value column)">improvise-c5v</a>: a records row groups one cell per measure, so editing a category column has to move every one of them. The long-format code moved only the cell under the cursor, which in wide format would tear the row apart — half its measures under the old coordinate, half under the new one.</p>
<h3 id="leaving-records-mode-cleans-empty-key-cells"><a class="anchor" href="#leaving-records-mode-cleans-empty-key-cells">#</a>leaving_records_mode_cleans_empty_key_cells</h3>
<p><a class="tid" href="../tickets.html#improvise-3zq" title="Records-mode Enter/Tab at bottom-right: persist empty record, re-enter editing">improvise-3zq</a> (bug #2): <code>AddRecordRow</code> creates a cell with an empty <code>CellKey</code> when no Page-axis categories supply coords — that cell serialises as <code> = 0</code> in <code>.improv</code> and re-appears on every records toggle. Leaving records mode must clean up any such meaningless records (the inverse of the <code>SortData</code> that runs on entry).</p>
<h3 id="enter-at-bottom-right-of-records-view-exits-editing"><a class="anchor" href="#enter-at-bottom-right-of-records-view-exits-editing">#</a>enter_at_bottom_right_of_records_view_exits_editing</h3>
<p><a class="tid" href="../tickets.html#improvise-3zq" title="Records-mode Enter/Tab at bottom-right: persist empty record, re-enter editing">improvise-3zq</a> (bug #1): <code>Enter</code> on the bottom-right cell of records view should commit and leave edit mode. Previously <code>CommitAndAdvance</code> pushed an <code>EnterEditAtCursor</code> effect unconditionally, so the cursor stayed put and editing re-opened on the same cell.</p>
<h4 id="helper-records-model-with-two-rows"><a class="anchor" href="#helper-records-model-with-two-rows">#</a>Helper — <code>records_model_with_two_rows</code></h4>
<p>Not a test — the fixture for every Tab/Enter boundary test in this file.</p>
<p>A records-mode app with two data rows, for testing <code>Tab</code>/<code>Enter</code> behaviour at boundaries. In wide format a row is one coordinate tuple, so the two rows are two <code>Region</code> items, each carrying both measures (<a class="tid" href="../tickets.html#improvise-c5v" title="Improve records view data entry by making each measure a normal value column (wide format instead of synthetic Value column)">improvise-c5v</a>).</p>
<h3 id="tab-on-bottom-right-of-records-inserts-below"><a class="anchor" href="#tab-on-bottom-right-of-records-inserts-below">#</a>tab_on_bottom_right_of_records_inserts_below</h3>
<p><a class="tid" href="../tickets.html#improvise-hmu" title="TAB/Enter on bottom-right of records view should insert record below (TAB→first cell, Enter→same column); o inserts below cursor">improvise-hmu</a>: <code>Tab</code> on the bottom-right cell of records view inserts a new record below and moves to the first cell of the new row in edit mode.</p>
<h4 id="note-the-wizard-key-tests-module"><a class="anchor" href="#note-the-wizard-key-tests-module">#</a>Note — the <code>wizard_key_tests</code> module</h4>
<p>Behavioural coverage for the import wizard&#39;s key handling (<a class="tid" href="../tickets.html#improvise-2wm" title="WizardKey effect embeds the import-wizard state machine inside Effect::apply">improvise-2wm</a>). The wizard is driven entirely through <code>handle_key</code>, so these tests pin the user-visible contract independently of whether the step/key decisions live in an effect or in a command.</p>
<h3 id="name-model-typing-edits-the-name-and-enter-confirms-the-import"><a class="anchor" href="#name-model-typing-edits-the-name-and-enter-confirms-the-import">#</a>name_model_typing_edits_the_name_and_enter_confirms_the_import</h3>
<p><code>Enter</code> at <code>NameModel</code> builds and parks on the <code>Done</code> confirmation; a second <code>Enter</code> there installs the workbook and closes the wizard (<a class="tid" href="../tickets.html#improvise-907" title="Import wizard step machine: validate early, allow going back, make the terminal step real">improvise-907</a>, from <a class="tid" href="../tickets.html#improvise-c6j" title="WizardStep::Done is unreachable from the UI">improvise-c6j</a>).</p>
<h3 id="review-proposals-blocks-when-no-category-is-accepted"><a class="anchor" href="#review-proposals-blocks-when-no-category-is-accepted">#</a>review_proposals_blocks_when_no_category_is_accepted</h3>
<p>BUG (<a class="tid" href="../tickets.html#improvise-907" title="Import wizard step machine: validate early, allow going back, make the terminal step real">improvise-907</a>, from <a class="tid" href="../tickets.html#improvise-gfq" title="Import wizard defers the &#39;no category accepted&#39; failure to the very last keystroke">improvise-gfq</a>): an import whose fields all classify as <code>Measure</code> used to walk the user through <code>DefineFormulas</code> and <code>NameModel</code> before <code>build_model</code> refused. The refusal now happens at <code>ReviewProposals</code>, where <code>c</code> can still change a field&#39;s kind.</p>
<h3 id="left-walks-back-through-the-steps-taken"><a class="anchor" href="#left-walks-back-through-the-steps-taken">#</a>left_walks_back_through_the_steps_taken</h3>
<p>BUG (<a class="tid" href="../tickets.html#improvise-907" title="Import wizard step machine: validate early, allow going back, make the terminal step real">improvise-907</a>, from <a class="tid" href="../tickets.html#improvise-o1c" title="Import wizard has no way back; Esc discards all field decisions silently">improvise-o1c</a>): reaching a later step and wanting to change a field kind meant <code>Esc</code>, which discarded every decision. <code>Left</code> walks back through the path actually taken (gotcha 22).</p>
<h3 id="done-esc-returns-to-name-model-and-drops-the-built-model"><a class="anchor" href="#done-esc-returns-to-name-model-and-drops-the-built-model">#</a>done_esc_returns_to_name_model_and_drops_the_built_model</h3>
<p><code>Esc</code> at <code>Done</code> goes back to <code>NameModel</code> rather than discarding the import, and the workbook built for confirmation is dropped so a later accept cannot install stale field decisions.</p>
<h4 id="note-the-basis-tests-module-fixture"><a class="anchor" href="#note-the-basis-tests-module-fixture">#</a>Note — the <code>basis_tests</code> module fixture</h4>
<p>The two lower tiers of a batch&#39;s basis (ADR 001, <a class="tid" href="../tickets.html#improvise-xwi6" title="Concurrent edits: basis check (ADR 001, layer 1 — the correctness floor)">improvise-xwi6</a>). Fixture note: two regular categories, two measures and two records, deliberately. A one-category fixture cannot tell <code>TransposeAxes</code> from <code>CycleAxis</code>, a one-record fixture cannot tell &quot;the record it touched&quot; from &quot;every record&quot;, and a one-measure fixture cannot tell a record from a cell — the three distinctions these tests exist to draw.</p>
<h3 id="a-records-two-measures-share-one-record-key"><a class="anchor" href="#a-records-two-measures-share-one-record-key">#</a>a_records_two_measures_share_one_record_key</h3>
<p>The record is the coordinate tuple with <code>_Measure</code> gone, and both measures of one coordinate name the same record, which is the whole point of the unit.</p>
<h3 id="a-layout-change-moves-the-layout-version-and-no-record"><a class="anchor" href="#a-layout-change-moves-the-layout-version-and-no-record">#</a>a_layout_change_moves_the_layout_version_and_no_record</h3>
<p>Tier 2 moves, tier 3 does not. A shape change invalidates every in-flight <code>CellData</code> batch through <code>layout_version</code> alone; it must not also masquerade as a write to any record.</p>
<p>Fixture repaired under <a class="tid" href="../tickets.html#improvise-75ld" title="A refused or no-op AddCategory still bumps layout_version and raises dirty">improvise-75ld</a>: it asked for <code>SetAxis { category: &quot;Month&quot;, axis: Axis::Column }</code> against a fixture where <code>on_category_added</code> had already seated <code>Month</code> on Column — so the &quot;layout change&quot; was a no-op, and the test passed only because the tier was bumped off the declaration. It now asserts the axis before and after, and asks for <code>Axis::Row</code>. This is the failure that turned &quot;reclassify three effects&quot; into &quot;stop reading the tier off the classification&quot;. Injected fault: the same as <code>both_shape_counters_move_for_every_reshaping_command</code> — fails naming <code>SetAxis</code>.</p>
<h3 id="a-loaded-document-is-dirty-because-its-path-still-names-the-old-one"><a class="anchor" href="#a-loaded-document-is-dirty-because-its-path-still-names-the-old-one">#</a>a_loaded_document_is_dirty_because_its_path_still_names_the_old_one</h3>
<p><strong>A loaded document is dirty, and that is a behaviour change with a reason</strong> (<a class="tid" href="../tickets.html#improvise-gv9r" title="[bug] A wire model command never marks the document dirty">improvise-gv9r</a>, raised by the independent review of PR #26).</p>
<p><code>LoadModel</code> is <code>Model(WholeWorkbook)</code>, so <code>apply_effects</code> raises the flag — where before that branch <code>:e other.improv</code> left the document reporting clean. Clean was the wrong answer: <code>LoadModel</code> does <strong>not</strong> move <code>model_state.file_path</code>, so after it the in-memory document is a different one from the file the path names, and the bytes on disk and the bytes in memory really have parted company. That is all <code>dirty</code> claims.</p>
<p>It is worth a test rather than a comment because of what it gates: <code>autosave_if_needed</code> and the <code>:q</code> refusal both read this flag, so a user who loads and quits is now asked rather than silently dropping a document whose path is stale. <code>:q!</code> still discards. The path-staleness itself is pre-existing and is reported separately; this pins only that the flag follows from it.</p>
<h3 id="a-view-only-batch-leaves-the-document-clean"><a class="anchor" href="#a-view-only-batch-leaves-the-document-clean">#</a>a_view_only_batch_leaves_the_document_clean</h3>
<p>The negative arm of <a class="tid" href="../tickets.html#improvise-gv9r" title="[bug] A wire model command never marks the document dirty">improvise-gv9r</a>&#39;s raise, and it must be aimed at <code>apply_effects</code> rather than at a <code>Command</code>: a view-slice command is folded straight into <code>ViewState</code> and never reaches this loop, so a negative written against the session door would pass however unconditional the raise was. It is a <code>ViewOnly</code> effect <strong>run through the loop</strong> that discriminates.</p>
<p>What it pins: a client&#39;s cursor, status line or panel does not put the document out of step with the bytes on disk, so it must not arm an unsaved-changes prompt or an autosave.</p>
<h3 id="a-cell-edit-moves-only-the-record-it-touched"><a class="anchor" href="#a-cell-edit-moves-only-the-record-it-touched">#</a>a_cell_edit_moves_only_the_record_it_touched</h3>
<p>Tier 3 moves for the record written and for no other, and tier 2 stays put: a cell edit does not invalidate batches that merely share the document.</p>
<h3 id="a-coordinate-rename-is-one-write-to-each-record-not-one-per-measure"><a class="anchor" href="#a-coordinate-rename-is-one-write-to-each-record-not-one-per-measure">#</a>a_coordinate_rename_is_one_write_to_each_record_not_one_per_measure</h3>
<p>The unit is the record, not the cell. Renaming a coordinate in wide records mode moves <em>every</em> measure cell in the row (gotcha 1), and that is ONE write to the record it left and ONE to the record it arrived at — not one per measure.</p>
<h3 id="an-aborted-batch-bumps-nothing-it-did-not-run"><a class="anchor" href="#an-aborted-batch-bumps-nothing-it-did-not-run">#</a>an_aborted_batch_bumps_nothing_it_did_not_run</h3>
<p>Gotcha 21: a batch that aborts halfway must bump nothing after the abort — in either tier. <code>apply_effects</code> accumulates the <code>Dirty</code> inside the loop for exactly this reason, and the versions are read off the same place.</p>
<h3 id="a-write-that-changes-nothing-moves-no-version"><a class="anchor" href="#a-write-that-changes-nothing-moves-no-version">#</a>a_write_that_changes_nothing_moves_no_version</h3>
<p>A write that lands the value already there invalidates nobody&#39;s basis, so it moves no version. This is a deliberate choice of semantics — observed change, not &quot;a <code>CellData</code> effect ran&quot; — and the safe direction, because the alternative rejects batches for nothing.</p>
<h3 id="the-version-tiers-are-not-in-the-view-state"><a class="anchor" href="#the-version-tiers-are-not-in-the-view-state">#</a>the_version_tiers_are_not_in_the_view_state</h3>
<p>The counters are shared document state on <code>App</code>, never on the portable session slice (gotcha 30): a client inheriting another&#39;s idea of how far the document has moved is exactly the stale basis this machinery exists to catch.</p>
<h3 id="a-page-flip-under-prune-empty-invalidates-the-cursor"><a class="anchor" href="#a-page-flip-under-prune-empty-invalidates-the-cursor">#</a>a_page_flip_under_prune_empty_invalidates_the_cursor</h3>
<p><a class="tid" href="../tickets.html#improvise-7kvw" title="SetPageSelection renumbers the grid under prune_empty and does not reset the cursor">improvise-7kvw</a>, the case <a class="tid" href="../tickets.html#improvise-9mo9" title="[bug] Geometry effects that renumber the grid but never reset the cursor">improvise-9mo9</a> decided the other nine and deliberately left open. <code>SetPageSelection</code> changes which records are visible and not which categories frame them, so a grid position normally keeps naming the same cell. Under <code>View::prune_empty</code> that is false: which rows survive pruning is a function of the page selection, so a page flip renumbers the grid while neither <code>reset_cursor</code> nor <code>cursor_epoch</code> moved — gotcha 24&#39;s failure in both directions at once, the host&#39;s own cursor stale and no client told.</p>
<p>9mo9 did not patch it because <code>[</code>/<code>]</code> paging is constant navigation and sending the cursor home on every flip is worse than the hole when <code>prune_empty</code> is off, which is the default. So the reset is conditional on <code>prune_empty</code>, and on the selection actually moving.</p>
<p>The fixture has three regions and two quarters whose non-empty rows differ, so position 0 names <code>North</code> on Q1 and <code>South</code> on Q2. The test asserts the row labels really differ before asserting the epoch moved; the negative arm asserts they really <em>do not</em> differ with pruning off, so neither case can pass vacuously.</p>
<p>Injected fault: drop the reset. The prune arm fails. Injected fault: make the reset unconditional. The no-prune arm fails.</p>
<h3 id="a-page-flip-without-prune-empty-leaves-the-cursor-alone"><a class="anchor" href="#a-page-flip-without-prune-empty-leaves-the-cursor-alone">#</a>a_page_flip_without_prune_empty_leaves_the_cursor_alone</h3>
<p>The negative arm of the above, and it is load-bearing for the same reason gotcha 24 gives for <code>SetNumberFormat</code>: without it, &quot;reset on every page flip&quot; passes the positive test and costs the user their position on every <code>[</code>/<code>]</code>.</p>
<h3 id="selecting-the-page-already-selected-invalidates-no-cursor"><a class="anchor" href="#selecting-the-page-already-selected-invalidates-no-cursor">#</a>selecting_the_page_already_selected_invalidates_no_cursor</h3>
<p>The third arm, and the one the user actually hits. <code>SetPageSelection</code> resets the cursor only when the selection <strong>moved</strong>, and that guard shipped untested: deleting <code>moved &amp;&amp;</code> left the whole workspace green at 1416, which review caught.</p>
<p>It is not an edge case. <code>PageNext</code>/<code>PagePrev</code> are odometer-style and emit a <code>SetPageSelection</code> for <strong>every</strong> page category on every keypress, so with more than one page category the non-carrying categories are always no-ops, and a single one-item category re-selects the same item every time. Unguarded, each such keystroke bumps <code>cursor_epoch</code>, sends the host&#39;s cursor to the origin and broadcasts <code>Command::ResetCursor</code> to every client on that view — <a class="tid" href="../tickets.html#improvise-75ld" title="A refused or no-op AddCategory still bumps layout_version and raises dirty">improvise-75ld</a>&#39;s &quot;a no-op that refused everybody else&quot;, one tier down and aimed at the cursor instead of the basis.</p>
<p>The test asserts both preconditions <strong>before</strong> applying the effect — that the fixture really is on <code>Q1</code>, and that the grid really does prune — and that the row labels did not move before asserting the epoch stood still. So it cannot pass by re-selecting a page that was never selected, or by testing a grid that does not prune.</p>
<p>Both <code>before</code> placements are load-bearing, and both were wrong once. The page check used to run <strong>after</strong> the effect, which sets the page to <code>Q1</code>, so it could not see a fixture that started elsewhere: with the fixture moved to <code>Q2</code> the test did fail, but at the <em>row-labels</em> assertion, under a message about renumbering. The sentence naming the property was not the assertion guarding it. The <code>prune_empty</code> check did not exist at all, and with pruning off the reset never fires whatever <code>moved</code> says — so <code>paged_model(false)</code> plus the deleted guard left the test <strong>green</strong>, proving nothing, with nothing saying so.</p>
<p>Both preconditions read <code>App::view()</code>, the view the effect writes through <code>app.view_mut()</code>, and not <code>Workbook::active_view()</code>, which is the document&#39;s <em>default</em> and is deliberately allowed to differ from it (gotcha 29). They agree in this single-client fixture, so the distinction is latent rather than a live bug — but an assertion about what an effect did must read the view that effect wrote.</p>
<p>Injected fault: delete <code>moved &amp;&amp;</code>. Fails by name, alone. Injected fault: <code>paged_model(false)</code> <strong>and</strong> delete <code>moved &amp;&amp;</code>. Fails on the <code>prune_empty</code> assertion; before that assertion existed, this combination passed. Injected fault: <code>paged_model</code>&#39;s fixture starts on <code>Q2</code>. Fails on the page-selection assertion, which now names it; before the move it failed on the row labels instead.</p>
<h3 id="a-foreign-layout-is-not-shown-the-hosts-staged-drill-text"><a class="anchor" href="#a-foreign-layout-is-not-shown-the-hosts-staged-drill-text">#</a>a_foreign_layout_is_not_shown_the_hosts_staged_drill_text</h3>
<p><a class="tid" href="../tickets.html#improvise-pubt" title="[bug] display_value_in reads the host&#39;s drill edits against a foreign layout">improvise-pubt</a>, filed with an argument for unreachability that the test disproves. <code>project::cell_display</code> fills <code>edit_text</code> from <code>App::display_value_in(layout, key)</code>, and since <a class="tid" href="../tickets.html#improvise-jytm" title="Split active_view: document default vs per-client authoritative (improvise-g0f)">improvise-jytm</a> that layout may be another client&#39;s. <code>drill_state.pending_edits</code> are keyed by <code>(row index, RecordCol)</code> against the <strong>host&#39;s</strong> drill layout (gotcha 1), and a row index in one layout names nothing in another.</p>
<p>The ticket read the arm as unreachable because &quot;a foreign layout built by <code>GridLayout::new</code> carries no frozen records, so it should not produce a synthetic key&quot;. That is wrong: records mode <strong>auto-detects</strong> from the axes (gotcha 1), so an ordinary saved view with <code>_Index</code> on Row and <code>_Dim</code> on Column gives <code>GridLayout::new</code> a records layout whose <code>cell_key</code> returns exactly the synthetic <code>(_Index, _Dim)</code> key the drill arm matches on. The test fails on the original code, naming the host&#39;s staged text read out of a layout it was never staged against.</p>
<p>The fix is <code>App::staged_drill_edit</code>, which consults <code>drill_state</code> only when <code>std::ptr::eq(layout, &amp;self.layout)</code>. A pointer comparison rather than a name comparison because <code>grid_in</code> reuses <code>&amp;app.layout</code> by reference where the view names agree and derives a fresh one otherwise, so identity <em>is</em> the question being asked. Whether the host&#39;s pending drill text should reach any other client at all is <a class="tid" href="../tickets.html#improvise-tom1" title="Drill pending edits are invisible to remote clients, and there are now three answers to &#39;what text is in this cell&#39;">improvise-tom1</a> and is untouched here.</p>
<p>The positive arm — the host&#39;s own layout still shows <code>STAGED</code> — is in the same test, or &quot;never consult the drill&quot; passes, which would blank the TUI&#39;s own drill editor. The drill fixture needs a real <code>RecordRow</code>, not an empty <code>Vec</code>: the host&#39;s layout iterates the frozen snapshot, so an empty one gives a zero-row grid and the positive arm cannot be built.</p>
<h3 id="a-default-that-names-nothing-says-which-invariant-broke"><a class="anchor" href="#a-default-that-names-nothing-says-which-invariant-broke">#</a>a_default_that_names_nothing_says_which_invariant_broke</h3>
<p><a class="tid" href="../tickets.html#improvise-h074" title="Two edges of App::resolve_view: a worse panic message, and a host-side DeleteView bypass">improvise-h074</a>. <code>App::resolve_view</code> falls back to <code>workbook.active_view</code> for a name the document has not got and then indexes <code>workbook.views[default]</code>. Several of these call sites previously went through <code>Workbook::active_view()</code>, which carries <code>.expect(&quot;active_view always names an existing view&quot;)</code>; <code>IndexMap</code>&#39;s <code>Index</code> impl panics with no message at all, so a workbook whose default names nothing — reachable only by constructing the struct directly, since <code>create_view</code>/<code>delete_view</code> maintain it — failed with a bare index panic where it used to name the broken invariant.</p>
<p>Injected fault: restore <code>&amp;workbook.views[default]</code>. The test fails on the expected substring.</p>
<hr />
<h2 id="ui-project-rs"><a class="anchor" href="#ui-project-rs">#</a><code>ui/project.rs</code></h2>
<h3 id="a-window-reaching-the-last-row-carries-the-trailing-group-header"><a class="anchor" href="#a-window-reaching-the-last-row-carries-the-trailing-group-header">#</a>a_window_reaching_the_last_row_carries_the_trailing_group_header</h3>
<p>A collapsed group at the bottom of the grid has no items to hang off, and is still the only row that says those rows exist. A window that reaches the last row therefore carries it.</p>
<p>It must be <em>seen</em>; it is not yet actionable in a browser. <a href="https://github.com/fiddlerwoaroof/improvise/blob/main/web/app.js"><code>web/app.js</code></a> registers no listener on a group <code>&lt;th&gt;</code>, and <code>toggle-group-under-cursor</code> is still in <code>the_command_names_that_still_need_the_host_are_these</code>, so <code>z</code> resolves <code>Upstream</code> into <code>Host::handle</code>&#39;s empty vec — a black hole, not a slow path (<code>doctrine(an_upstream_key_is_a_black_hole)</code>). The TUI can expand it today; the browser can only show it.</p>
<h4 id="helper-app-with-a-trailing-group"><a class="anchor" href="#helper-app-with-a-trailing-group">#</a>Helper — <code>app_with_a_trailing_group</code></h4>
<p>Not a test — the fixture for that test and for its stops-short arm.</p>
<p><code>Region</code> on Row with <code>East</code> in a group that comes <strong>last</strong>, so the group header is the final entry on the axis and nothing follows it. That is the shape <code>axis_frame</code>&#39;s trailing rule is about.</p>
<h3 id="a-window-that-stops-short-does-not-carry-what-is-below-it"><a class="anchor" href="#a-window-that-stops-short-does-not-carry-what-is-below-it">#</a>a_window_that_stops_short_does_not_carry_what_is_below_it</h3>
<p>The other arm, and the one that would make a browser scrolled to the middle of a document draw the bottom of it: a window that stops short of the last row carries nothing below its last item.</p>
<h3 id="a-window-opening-inside-a-group-names-only-the-nearest"><a class="anchor" href="#a-window-opening-inside-a-group-names-only-the-nearest">#</a>a_window_opening_inside_a_group_names_only_the_nearest</h3>
<p>A window opening <em>inside</em> a group still names it — otherwise a browser could not say which group a column or row belongs to — while <code>body_from</code> declines to paint a header that sits above the window.</p>
<p><strong>And only the nearest one comes.</strong> The fixture puts <strong>two</strong> headers above the window (<code>Hidden</code> is collapsed, so it emits a header and no items, and <code>Late</code>&#39;s follows it immediately) because that is the only way to tell &quot;the nearest header comes&quot; from &quot;every header above comes&quot;. With one group above, deleting the compaction leaves this green — it is a <em>cost</em> property, O(the groups a deep scroll crossed), and correctness alone cannot see it: <code>group_of</code> still finds the nearest and <code>body_from</code> still starts at the first item either way.</p>
<h4 id="helper-app-with-consecutive-headers"><a class="anchor" href="#helper-app-with-consecutive-headers">#</a>Helper — <code>app_with_consecutive_headers</code></h4>
<p>Not a test — the fixture for that test and for <code>consecutive_headers_between_two_rows_all_come</code>.</p>
<p>Two consecutive group headers between two rows: <code>Hidden</code> is collapsed, so it emits a header and no items, and <code>Late</code>&#39;s header follows it immediately.</p>
<h3 id="consecutive-headers-between-two-rows-all-come"><a class="anchor" href="#consecutive-headers-between-two-rows-all-come">#</a>consecutive_headers_between_two_rows_all_come</h3>
<p>Headers between two in-window rows all come, not just the nearest.</p>
<p>Only the nearest wins <code>group_of</code>, so a compaction that kept one would still answer every question about <em>membership</em> correctly — and would silently lose the <code>Hidden</code> header, which is the only thing on screen that says those rows exist at all, and the only thing that <em>will</em> be clickable once the browser can toggle a group. It cannot today: <code>z</code> is <code>Upstream</code> and answered with nothing.</p>
<p>Above the window the rule is the opposite, and that arm is <code>a_window_opening_inside_a_group_names_it_without_painting_it</code>.</p>
<h3 id="folding-what-the-server-emits-reproduces-what-the-server-has"><a class="anchor" href="#folding-what-the-server-emits-reproduces-what-the-server-has">#</a>folding_what_the_server_emits_reproduces_what_the_server_has</h3>
<p>The contract of the whole module: a client that folds what the server sends ends up holding what the server has. Not the fold axiom — the yardstick (<code>server_truth</code>) is built independently of the projections.</p>
<h4 id="helper-server-truth"><a class="anchor" href="#helper-server-truth">#</a>Helper — <code>server_truth</code></h4>
<p>Not a test — the yardstick that test and its siblings measure a fold against.</p>
<p>What the server believes the client should be holding, built straight from the authoritative state — the yardstick every fold is measured against, and built independently of the projections so the fold tests are not vacuous (<code>doctrine(fold_tests_must_test_order_not_the_axiom)</code>).</p>
<h3 id="removing-a-category-empties-the-clients-formula-panel"><a class="anchor" href="#removing-a-category-empties-the-clients-formula-panel">#</a>removing_a_category_empties_the_clients_formula_panel</h3>
<p>Removing a category removes every formula that targets it — <code>Model::remove_category</code> ends with <code>formulas.retain(|f| f.target_category != name)</code>. <code>RemoveCategory</code> is <code>LayoutGeometry</code>, and <code>Dirty::of(LayoutGeometry)</code> reported <code>formulas: false</code>, so the formula panel was never told: a client&#39;s <code>FormulaCache</code> kept showing a formula the document no longer has, indefinitely, with nothing to bring it back short of a reconnect. (Gotcha 35.)</p>
<h3 id="a-late-client-converges-with-one-that-followed-from-the-start"><a class="anchor" href="#a-late-client-converges-with-one-that-followed-from-the-start">#</a>a_late_client_converges_with_one_that_followed_from_the_start</h3>
<p>Bootstrap is a compaction, not a replay: a client that connects late and folds only the seed lands exactly where one that followed the whole stream is.</p>
<h3 id="the-projected-extent-is-the-whole-grid-not-the-window"><a class="anchor" href="#the-projected-extent-is-the-whole-grid-not-the-window">#</a>the_projected_extent_is_the_whole_grid_not_the_window</h3>
<p>The extent is the <strong>whole</strong> grid, not the window (<a class="tid" href="../tickets.html#improvise-bpq2" title="Client-side key resolution: what must the projections carry?">improvise-bpq2</a>).</p>
<p>A client resolving its own cursor keys clamps against this, so a producer that read it off the projected ranges would stop every browser cursor at the bottom of the screen — and would look right in the TUI, whose cache covers the whole grid at offsets zero (gotcha 44). The fixture is deliberately <strong>not square</strong> (3 rows, 2 columns) and the window is smaller in both directions, so it separates &quot;the whole grid&quot; from &quot;the window&quot; <em>and</em> from &quot;rows and cols swapped&quot;.</p>
<h3 id="a-reshaped-grid-folds-to-the-desired-state"><a class="anchor" href="#a-reshaped-grid-folds-to-the-desired-state">#</a>a_reshaped_grid_folds_to_the_desired_state</h3>
<p>Reshaping is the case the ordering exists for. Growing is the half that bites: cells at positions the client does not have yet are dropped, so the labels that create those positions must go first. Shrinking is here for the other direction — cells that no longer fit must not be sent into a grid that has already been cut down.</p>
<h3 id="a-renamed-column-whose-total-did-not-change-still-gets-its-total-back"><a class="anchor" href="#a-renamed-column-whose-total-did-not-change-still-gets-its-total-back">#</a>a_renamed_column_whose_total_did_not_change_still_gets_its_total_back</h3>
<p><a class="tid" href="../tickets.html#improvise-hkt6" title="Three small totality gaps found in review">improvise-hkt6</a>, found by Copilot on PR 20. The diff must carry the mirror to <code>desired</code>, and it stopped doing so for one shape: a column <strong>renamed</strong> while its total stayed the same number.</p>
<p><code>apply</code> blanks the total under a relabelled column (gotcha 33(b)), so the client&#39;s total went blank; but the diff compared <code>mirror.totals</code> against <code>desired.totals</code>, found them equal, and sent no <code>Totals</code>. <code>ClientSession::project_diff</code> then assigns <code>self.grid = desired</code>, so the server believed the number was still on screen — a cache wrong <strong>forever</strong>, with nothing in the stream to correct it, which is precisely the direction gotcha 35 keeps <code>Dirty::of</code> sloppy to avoid.</p>
<p>The assertion is the one <code>a_reshaped_grid_folds_to_the_desired_state</code> makes — fold the updates onto the mirror and require the result to be <code>desired</code> — because &quot;a <code>Totals</code> update was emitted&quot; would pass for a producer that emitted the wrong totals.</p>
<p>The fixture renames the <strong>middle</strong> column and keeps every total equal, so it separates &quot;re-sends what the relabel blanked&quot; from &quot;re-sends whenever the labels move at all&quot;.</p>
<h3 id="renaming-an-axis-category-resends-every-cell"><a class="anchor" href="#renaming-an-axis-category-resends-every-cell">#</a>renaming_an_axis_category_resends_every_cell</h3>
<p>The producer half of the category-rename rule, and the reason the <code>apply</code> half is not cosmetic: the two compose into &quot;wrong forever&quot; without it.</p>
<p><code>grid_updates</code> diffs the cells against the <strong>framed</strong> mirror — the mirror with the frame updates already folded in. So an <code>apply</code> that kept the cells through a <code>cats</code> change made the producer agree they matched and send nothing, <code>ClientSession::project_diff</code> then assigned <code>self.grid = desired</code>, and the client held cells answering the old coordinates with no update in the stream that would ever correct them.</p>
<p>An axis swap is the case that bites: two categories can legally share an item name, so <code>Region = [North]</code> becoming <code>Department = [North]</code> leaves the labels identical and the <code>CellId</code>s <strong>valid and pointing at the other record</strong>.</p>
<h3 id="a-session-that-switches-views-is-projected-the-view-it-switched-to"><a class="anchor" href="#a-session-that-switches-views-is-projected-the-view-it-switched-to">#</a>a_session_that_switches_views_is_projected_the_view_it_switched_to</h3>
<p>The original bug: <code>ClientSession::apply</code> folded straight into the session&#39;s mirror, so a <code>Command::SwitchView</code> moved the mirrored <code>active_view</code> while the workbook&#39;s stayed put (gotcha 29). <code>viewport()</code> then read the cursor under the new view&#39;s key and the next <code>project()</code> built the grid from the <em>other</em> view.</p>
<p><a class="tid" href="../tickets.html#improvise-jytm" title="Split active_view: document default vs per-client authoritative (improvise-g0f)">improvise-jytm</a> answers it the other way round — the mirror is now authoritative and <code>grid_in</code> is built from it — so the assertions moved with it: the asker&#39;s mirror names &quot;Second&quot;, the document&#39;s default is still &quot;Default&quot;, and the rows it is sent are the ones &quot;Second&quot; has. The fixture&#39;s two views are transposes of each other, so &quot;was I sent the right grid&quot; cannot be answered by accident.</p>
<p>Two faults injected 2026-09-20, both of which fail it. Pointing <code>grid_in</code> at <code>app.view_name()</code> regardless of the session reproduces the original defect exactly. Removing <code>ClientSession</code>&#39;s <code>projected_view</code> observation is the <em>new</em> way to reproduce it: a switch is <code>ViewOnly</code>, so nothing in <code>Dirty</code> carries it and the session must notice its own move or never rebuild its grid at all.</p>
<h4 id="helper-app-with-two-views"><a class="anchor" href="#helper-app-with-two-views">#</a>Helper — <code>app_with_two_views</code></h4>
<p>Not a test — the fixture for every lockstep and rebase test in this file.</p>
<p>A second view whose axes are the first one&#39;s transposed, so the two grids cannot be confused for each other: &quot;Default&quot; rows are regions, &quot;Second&quot; rows are measures.</p>
<h3 id="a-view-switch-moves-only-the-switchers-mirror"><a class="anchor" href="#a-view-switch-moves-only-the-switchers-mirror">#</a>a_view_switch_moves_only_the_switchers_mirror</h3>
<p>The successor to <code>a_view_switch_moves_every_sessions_mirror_not_just_the_switchers</code>, which pinned the lockstep <a class="tid" href="../tickets.html#improvise-jytm" title="Split active_view: document default vs per-client authoritative (improvise-g0f)">improvise-jytm</a> removes. The model is shared and the UI is not, so a bystander keeps its view, its scroll and its grid.</p>
<p>Three assertions, because the old bug can come back through three different doors: the bystander&#39;s mirrored <code>active_view</code>, its <code>row_offset</code> (nonzero, so a mirror reset shows as a move rather than coinciding with the origin), and whether <code>project_all</code> names it at all. That third one is the sharpest — a bystander repainted for somebody else&#39;s switch is invisible in the first two, since it would be repainted with its <em>own</em> view&#39;s content and look right.</p>
<p>The scroll is drained through <code>project_all</code> before the switch, so the fan-out the test reads names only what the switch caused.</p>
<p>Fault injected 2026-09-20: restoring the <code>adopt_active_view</code> loop (push the accepted view into every mirror) fails it, as does removing the <code>projected_view</code> observation.</p>
<h3 id="a-refused-switch-corrects-the-asker-and-nobody-else"><a class="anchor" href="#a-refused-switch-corrects-the-asker-and-nobody-else">#</a>a_refused_switch_corrects_the_asker_and_nobody_else</h3>
<p><a class="tid" href="../tickets.html#improvise-6c6q" title="[bug] apply_switch relays a refused switch to everyone but the asker">improvise-6c6q</a>. <code>apply_switch</code> relayed <code>Broadcast::ToOthers</code>, so on the refused path every client <em>but</em> the one that was wrong was told the accepted name. The asker&#39;s own slice kept the name it had folded optimistically while its server-side mirror held the accepted one — gotcha 24&#39;s mismatch, arriving through the asker&#39;s own keystroke. <code>apply_switch</code>&#39;s doc comment claimed the hole was closed, which is worse than the hole.</p>
<p><strong>The test drives the asker&#39;s own <code>ViewState</code></strong>, folding the command the way a real client would before handing it to the door. That is the whole cost of the ticket: the sibling bug in <code>apply_model</code> shipped green because its test read a &quot;what do the others hear&quot; helper, which looks away from the asker by construction. There is deliberately no such helper in this test.</p>
<p><strong>The bystander is asserted from a view it can be moved OFF.</strong> A first version left it on the default, where a wrongly-addressed relay folds to a no-op — so the assertion read as coverage and was vacuous, which review demonstrated by injecting <code>ToAll</code> and watching this test pass. It now sits on &quot;Second&quot;, and the audience itself is asserted (<code>Broadcast::To(asker, …)</code>) rather than inferred from what somebody&#39;s slice folded to: the audience <strong>is</strong> the message (gotcha 36), so it is the thing to read.</p>
<p>Two faults injected 2026-09-20, both of which fail it: relaying <code>ToOthers</code> (the original <a class="tid" href="../tickets.html#improvise-6c6q" title="[bug] apply_switch relays a refused switch to everyone but the asker">improvise-6c6q</a> bug) and relaying <code>ToAll</code> (the over-correction). The second also fails <code>folding_the_relay_puts_a_refused_asker_back</code>, which reads frames rather than folded state.</p>
<h3 id="deleting-the-view-this-client-is-on-moves-it-to-the-default"><a class="anchor" href="#deleting-the-view-this-client-is-on-moves-it-to-the-default">#</a>deleting_the_view_this_client_is_on_moves_it_to_the_default</h3>
<p>The host-side half of <code>deleting_a_view_moves_the_client_that_was_on_it_and_tells_it</code>, and a desync that predates <a class="tid" href="../tickets.html#improvise-jytm" title="Split active_view: document default vs per-client authoritative (improvise-g0f)">improvise-jytm</a>: <code>Workbook::delete_view</code> moved the document&#39;s active view and nothing moved <code>ViewState::active_view</code>, so the TUI&#39;s cursor was read under a key naming a view that was gone. It was invisible while <code>draw_status</code> read the workbook&#39;s copy; <a class="tid" href="../tickets.html#improvise-k69y" title="[bug] draw_status reads the document&#39;s active view, not the client&#39;s">improvise-k69y</a> makes it read the client&#39;s, which is what turns it into a wrong badge.</p>
<p>The negative arm is the discriminating one: deleting a <em>different</em> view must leave this client where it is. Without it, unconditionally seating the client on the default would pass the positive assertion. The fixture therefore carries three views, and the first delete is of the one nobody is on.</p>
<p>Fault injected 2026-09-20: dropping the <code>view_state.active_view</code> line from <code>effect::DeleteView</code> fails it.</p>
<h3 id="deleting-a-view-moves-the-client-that-was-on-it-and-tells-it"><a class="anchor" href="#deleting-a-view-moves-the-client-that-was-on-it-and-tells-it">#</a>deleting_a_view_moves_the_client_that_was_on_it_and_tells_it</h3>
<p>A hole <a class="tid" href="../tickets.html#improvise-jytm" title="Split active_view: document default vs per-client authoritative (improvise-g0f)">improvise-jytm</a> opens and must therefore close. Before the split, <code>Workbook::delete_view</code> moved the document&#39;s active view and <code>adopt_active_view</code> pushed the result into every mirror, so deleting the view a client was on moved that client. The split removes both, and without <code>adopt_deleted_views</code> the client is left naming a view the document has not got: <code>App::resolve_view</code> falls back to the default, so it is <em>projected</em> the default&#39;s grid while its slice says otherwise, and <code>viewport()</code> reads its cursor under a key nothing will ever write again.</p>
<p>The fixture carries <strong>three</strong> views and the bystander sits on the one the delete does not remove. That is the whole discrimination: a first version left the bystander on the default, where a relay addressed to everybody folds to a no-op, and review demonstrated the vacuity by replacing <code>Broadcast::To</code> with <code>ToAll</code> and watching the test pass. The evidence entry had even <em>stated</em> the risk in one sentence and claimed the assertion covered it in the next — the exact shape this file exists to stop.</p>
<p>So four assertions, over three different surfaces. The <strong>audience</strong> is read off the <code>Broadcast</code> itself, because the audience is the message (gotcha 36). The client that lost its view moved, in its mirror <em>and</em> in its own slice, the latter driven by folding what the host relayed. And the bystander is still on &quot;Third&quot; — in its slice and in its mirror — which only means something because there is somewhere for a wrong relay to have moved it from.</p>
<p>The deletion itself is asserted, because <code>Workbook::delete_view</code> refuses to remove the last view: a fixture with one view would pass every other line here while deleting nothing.</p>
<p>Two faults injected 2026-09-20, both of which fail it: dropping the <code>adopt_deleted_views</code> call from <code>apply_model</code>, and addressing its relay <code>ToAll</code>.</p>
<h3 id="a-session-on-another-view-is-projected-that-views-axes"><a class="anchor" href="#a-session-on-another-view-is-projected-that-views-axes">#</a>a_session_on_another_view_is_projected_that_views_axes</h3>
<p>The hole review found in <a class="tid" href="../tickets.html#improvise-jytm" title="Split active_view: document default vs per-client authoritative (improvise-g0f)">improvise-jytm</a>&#39;s first cut: only the <strong>grid</strong> half of &quot;every projection follows the session&#39;s view&quot; was checked. <code>category_tree_in</code> and <code>tile_bar_in</code> are reached through <code>ClientSession::view_in</code>, and replacing that function&#39;s body with <code>app.view()</code> — the host&#39;s view — left the whole suite green.</p>
<p>The harm is worse than a wrong pixel. A category&#39;s axis is what the <strong>tile bar</strong> is for: it is the control a user reassigns axes with. Under the fault a client on another view sees the wrong axis for every category, in the widget it would use to change them, while the grid beside it is correct — nothing to notice and nothing that fails.</p>
<p><code>app_with_two_views</code> is what makes two assertions enough: &quot;Second&quot; is &quot;Default&quot; transposed, so reading the wrong view <strong>swaps</strong> Row and Column rather than blurring them, and the test names both categories rather than one.</p>
<p>Fault injected 2026-09-20: <code>ClientSession::view_in</code> returning <code>app.view()</code> fails it.</p>
<h3 id="walking-the-view-history-moves-no-document-state"><a class="anchor" href="#walking-the-view-history-moves-no-document-state">#</a>walking_the_view_history_moves_no_document_state</h3>
<p>The other hole review found: of the three effects <a class="tid" href="../tickets.html#improvise-jytm" title="Split active_view: document default vs per-client authoritative (improvise-g0f)">improvise-jytm</a> reclassified to <code>ViewOnly</code>, only <code>SwitchView</code> had a guard (<code>a_wire_view_switch_leaves_the_document_clean</code>). Putting <code>ViewBack</code> or <code>ViewForward</code> back to <code>Model(LayoutGeometry)</code> left all 1313 tests green — which is gotcha 3&#39;s whole point: a mis-declared kind is silent.</p>
<p>It asserts the <strong>consequences</strong> rather than the declaration, because those are what a reader can price. <code>App::apply_effects</code> raises <code>model_state.dirty</code> for any <code>Model(_)</code>, and the browser worker persists on that flag — so a mis-declared view-back rewrites the entire OPFS document (gotcha 41: <code>Storage</code> is whole-document) for a move that changed nothing. And it bumps <code>layout_version</code>, so every <em>other</em> client&#39;s next <code>Command::Model</code> is refused <code>LayoutMoved</code> (ADR 001) because somebody pressed a navigation key.</p>
<p>The <code>assert_ne!</code> on the active view before each pair of assertions is the check-the-checker: an empty history stack makes <code>ViewBack</code> a no-op, and a no-op dirties nothing whatever its kind.</p>
<p>Both effects are driven, because they are separate <code>kind()</code> implementations and review injected them separately.</p>
<h3 id="a-fresh-session-opens-the-documents-default-view"><a class="anchor" href="#a-fresh-session-opens-the-documents-default-view">#</a>a_fresh_session_opens_the_documents_default_view</h3>
<p>The other half of <a class="tid" href="../tickets.html#improvise-jytm" title="Split active_view: document default vs per-client authoritative (improvise-g0f)">improvise-jytm</a>&#39;s contract, and the reason <code>Workbook::active_view</code> survives at all: it is what a freshly connected client opens. The fixture switches an <em>existing</em> session first, so a <code>connect</code> that read any client&#39;s view rather than the document&#39;s would be visible.</p>
<p>Fault injected 2026-09-20: pushing the accepted view into every mirror in <code>apply_switch</code> moves the document&#39;s default nowhere, but making <code>apply_switch</code> write <code>workbook.active_view</code> fails it.</p>
<h4 id="helper-diff"><a class="anchor" href="#helper-diff">#</a>Helper — <code>diff</code></h4>
<p>Not a test — the accessor every diff-subject test in this file reads its answer through.</p>
<p>The two halves of an <code>Applied::Diff</code>, for the tests whose subject is not the rebase. It panics rather than defaulting, so a command that starts replacing the document is a loud failure here and not a silent <code>Dirty::NOTHING</code>.</p>
<p><em>Note:</em> before the comment-cap wave this helper&#39;s doc comment was preceded by an orphaned paragraph (&quot;One connected client, as its host really drives it…&quot;) describing a type that no longer exists at that site. Dropped.</p>
<p><em>Both <code>a_model_command_that_moves_the_active_view_binds_every_mirror</code> and <code>a_model_commands_view_switch_reaches_the_asker_too</code> were retired by <a class="tid" href="../tickets.html#improvise-jytm" title="Split active_view: document default vs per-client authoritative (improvise-g0f)">improvise-jytm</a>, along with the <code>to_others</code> helper whose blind spot the second one existed to cover. No <code>ModelCommand</code> moves anybody&#39;s active view any more: <code>ViewBack</code>/<code>ViewForward</code> left the wire because the stack they pop is one client&#39;s history, and a document swap lands in the rebase arm. The property the second test defended — a correction must reach the client that folded it — is now <code>a_refused_switch_corrects_the_asker_and_nobody_else</code>, which drives the asker&#39;s own slice rather than a helper.</em></p>
<h3 id="a-geometry-command-invalidates-every-clients-cursor-including-the-askers"><a class="anchor" href="#a-geometry-command-invalidates-every-clients-cursor-including-the-askers">#</a>a_geometry_command_invalidates_every_clients_cursor_including_the_askers</h3>
<p><code>TransposeAxes</code> and <code>CycleAxis</code> call <code>app.reset_cursor()</code> because renumbering every row and column makes the old indices name different cells (gotcha 24) — but that reset only reached the authoritative <code>App</code>&#39;s own view slice. Each <code>ClientSession</code> keeps its own cursor in its mirrored <code>ViewState</code>, and nothing reset those, so every client was left indexing a grid that had changed shape under it: <code>Viewport::window</code> clips, so what a bystander actually saw was a <strong>blank</strong> grid, until it happened to scroll (<a class="tid" href="../tickets.html#improvise-gvnb" title="[bug] A geometry command resets the App cursor but not the session mirrors">improvise-gvnb</a>).</p>
<p>The asker is checked alongside the bystander, and that is not symmetry for its own sake. A <code>Command::Model</code> is the one kind a client cannot fold optimistically, so the asker is in exactly the same position as everyone else, which is why the reset is <code>Broadcast::ToAll</code> and not the <code>To(id, …)</code> a refused <code>SwitchView</code> correction is.</p>
<h3 id="a-model-command-that-does-not-reshape-the-grid-leaves-cursors-alone"><a class="anchor" href="#a-model-command-that-does-not-reshape-the-grid-leaves-cursors-alone">#</a>a_model_command_that_does_not_reshape_the_grid_leaves_cursors_alone</h3>
<p>The other arm. A model command that leaves the grid&#39;s shape alone must not throw anyone&#39;s cursor away: where a client is looking is its own UI, and only a change to the shared geometry may invalidate it. Without this, &quot;reset on every model command&quot; would pass the test above.</p>
<h3 id="a-bystanders-own-slice-follows-the-invalidation"><a class="anchor" href="#a-bystanders-own-slice-follows-the-invalidation">#</a>a_bystanders_own_slice_follows_the_invalidation</h3>
<p>And the bystander really is shown the new grid from the origin, rather than a window clipped to nothing. This is the half the mirror reset alone cannot give: the client&#39;s own slice is driven by what the host relays, so the test drives it the way <code>Host::command</code> does.</p>
<h4 id="helper-host-dispatch"><a class="anchor" href="#helper-host-dispatch">#</a>Helper — <code>host_dispatch</code></h4>
<p>Not a test — the relay stand-in that test and its siblings drive a client&#39;s own slice with.</p>
<p>A stand-in for <code>Host::command</code>&#39;s relay, driving one client&#39;s own slice. <code>to</code> is the client being driven and <code>from</code> the one that asked, because that pair is exactly what <code>Broadcast</code>&#39;s audience is about.</p>
<h3 id="a-model-command-that-leaves-the-active-view-alone-broadcasts-no-switch"><a class="anchor" href="#a-model-command-that-leaves-the-active-view-alone-broadcasts-no-switch">#</a>a_model_command_that_leaves_the_active_view_alone_broadcasts_no_switch</h3>
<p>Renamed in review: it was <code>..._broadcasts_nothing</code>, and since <a class="tid" href="../tickets.html#improvise-9mo9" title="[bug] Geometry effects that renumber the grid but never reset the cursor">improvise-9mo9</a> it asserts a <code>ResetCursor</code> broadcast, so the name read as a lie about its own assertion. No client folded an active view, so there is no <code>SwitchView</code> correction to make. What the others are told is the <code>Dirty</code> it produced — and, since <a class="tid" href="../tickets.html#improvise-9mo9" title="[bug] Geometry effects that renumber the grid but never reset the cursor">improvise-9mo9</a>, the <code>ResetCursor</code> an <code>AddCategory</code> owes, because a new category lands on an axis in every view. The assertion is the exact broadcast list rather than &quot;contains no <code>SwitchView</code>&quot;: a weaker form would go on passing if the cursor invalidation were dropped again.</p>
<h3 id="every-reshaping-model-command-invalidates-every-cursor"><a class="anchor" href="#every-reshaping-model-command-invalidates-every-cursor">#</a>every_reshaping_model_command_invalidates_every_cursor</h3>
<p>Only <code>TransposeAxes</code> and <code>CycleAxis</code> reset the cursor, while <code>SetAxis</code>, <code>HideItem</code>, <code>ShowItem</code>, <code>ToggleGroup</code>, <code>TogglePruneEmpty</code>, <code>AddCategory</code>, <code>RemoveCategory</code>, <code>AddItem</code>, <code>AddItemInGroup</code> and <code>RemoveItem</code> renumbered the grid and did not (<a class="tid" href="../tickets.html#improvise-9mo9" title="[bug] Geometry effects that renumber the grid but never reset the cursor">improvise-9mo9</a>). <code>SetAxis</code> is the case that shows the shape of the bug: <code>CycleAxis</code> <em>is</em> <code>SetAxis</code> to <code>Axis::next()</code>, so a pair differing only by an argument disagreed. Because <code>reset_cursor</code> is also the only writer of <code>cursor_epoch</code>, every one of these left the host&#39;s own cursor stale <em>and</em> broadcast nothing, which gotcha 24 records as strictly worse than doing neither — the diff then believes each client already holds origin-positioned content and sends nothing, so they stay stale permanently rather than until the next scroll.</p>
<p>Two faults were injected. Deleting <code>app.reset_cursor()</code> from <code>SetAxis</code> fails the bystander-cursor assertion (<code>left: 1, right: 0</code>). Changing the fixture so the item <code>ShowItem</code> reveals was never hidden fails the <em>first</em> assertion — &quot;ShowItem reshaped nothing, so this case is not evidence of anything&quot; — which is the one that stops this from becoming a table of commands asserted against a grid that did not move. That guard matters most for <code>AddItem</code>: the fixture puts two categories on Row precisely so an append to the inner one inserts a row inside every outer block, because on a single-level axis an append lands at the end and moves nothing, and a one-level fixture would have made the reset look unearned.</p>
<h4 id="helper-app-with-a-reshapeable-grid"><a class="anchor" href="#helper-app-with-a-reshapeable-grid">#</a>Helper — <code>app_with_a_reshapeable_grid</code></h4>
<p>Not a test. Two categories on Row, one of them grouped, one item hidden and one item left with no data — so <code>ShowItem</code> has something to reveal, <code>ToggleGroup</code> has a group to fold and <code>TogglePruneEmpty</code> has an empty row to prune. Every command in the table has something real to reshape, which is what the first assertion checks.</p>
<h3 id="drilling-resets-only-the-drill-views-cursor"><a class="anchor" href="#drilling-resets-only-the-drill-views-cursor">#</a>drilling_resets_only_the_drill_views_cursor</h3>
<p><a class="tid" href="../tickets.html#improvise-9mo9" title="[bug] Geometry effects that renumber the grid but never reset the cursor">improvise-9mo9</a> made <code>SetAxis</code> call <code>app.reset_cursor()</code>, and <code>DrillIntoCell</code> issues eight or so <code>SetAxis</code> effects to build the <code>_Drill</code> view. The PR claimed that was safe and <strong>did not test it</strong> — the claim was that <code>SwitchView</code> precedes the batch, so <code>reset_cursor</code> writes the <em>active</em> view&#39;s cursor, which by then is the freshly created <code>_Drill</code> one, while the view drilled <em>from</em> keeps its entry in <code>ViewState::cursors</code>. That is an ordering argument about effects inside a batch, which is exactly the class of claim gotcha 23 exists because people get wrong.</p>
<p>Two faults injected. Removing the <code>SwitchView</code> from <code>DrillIntoCell</code>&#39;s batch fails the <strong>first</strong> assertion — &quot;the drill did not switch views, so this proves nothing about per-view cursors&quot; — which is the guard that stops the test passing for the wrong reason if the drill ever stops switching. Changing <code>reset_cursor</code> to clear the whole <code>cursors</code> map rather than the active entry fails the third: <code>left: (0, 0), right: (1, 1)</code>, the cursor in the origin view thrown away.</p>
<p>It asserts both sides on purpose. That the <code>_Drill</code> view opens at its origin is half the property; that the <em>other</em> view keeps <code>(1, 1)</code> and <code>row_offset: 1</code> is the half that would have broken, and a test asserting only the first would pass against a map that had been emptied.</p>
<h3 id="an-accepted-no-op-invalidates-no-cursor"><a class="anchor" href="#an-accepted-no-op-invalidates-no-cursor">#</a>an_accepted_no_op_invalidates_no_cursor</h3>
<p>The third negative arm, found in review. Gating the reset on <code>Ok</code> is not the same as gating it on &quot;something changed&quot;, and six commands the document <em>accepts</em> changed nothing and reset every cursor anyway: <code>SetAxis</code> to the axis already held, <code>HideItem</code> on an already-hidden item, <code>ShowItem</code> on a never-hidden one, <code>AddItem</code> of an existing item (<code>Category::add_item</code> dedupes and returns <code>Ok</code>), and <code>RemoveItem</code>/<code>RemoveCategory</code> of something absent.</p>
<p>The first assertion is the non-vacuity guard and it is doing real work here: it uses <code>grid_shape</code> to prove each case really is a no-op, so a fixture drifting into one where the command <em>does</em> reshape fails rather than passes.</p>
<p><strong>Review then found five more cases the table could not reach by construction</strong>, because <code>app_with_a_reshapeable_grid</code> had no formula, no category on <code>Axis::None</code> and no unknown-name case — the one-category-fixture shape the doctrine names. The fixture now carries a <code>Profit</code> formula, a <code>Channel</code> category parked on <code>Axis::None</code>, and a <code>Ghost</code> entry hidden under a name the model never had, and the table covers: <code>SetAxis</code> on an unregistered category, <code>HideItem</code>/<code>ShowItem</code> on a name the grid never drew, <code>ToggleGroup</code> on a group that does not exist, and <code>AddItem { _Measure, Profit }</code>.</p>
<p>That last one is the one to remember. The guard it replaced read <code>category.items</code>, but <code>_Measure</code>&#39;s effective items are explicit ∪ formula targets (gotcha 7), so it called a drawn column &quot;new&quot;. <strong>Two of the five injections initially failed to reproduce, and both times the injection was wrong rather than the guard</strong>: the <code>ShowItem</code> case needed a hidden <em>ghost</em> name, since <code>is_hidden</code> is false for a name nobody hid; and the <code>AddItem</code> case had to compute its predicate <strong>before</strong> the add, because afterwards the item really is in <code>category.items</code>. An injection that does not reproduce the original ordering tests nothing — the same shape as the simulated-fix confirmation in gotcha 48.</p>
<p><code>ToggleGroup</code>&#39;s first guard was also wrong and this test caught it: <code>add_item_in_group</code> sets <code>item.group</code> without pushing to <code>Category::groups</code>, so asking <code>groups</code> said &quot;no such group&quot; about the fixture&#39;s real one.</p>
<p>Faults injected, two, both caught by name: removing <code>SetAxis</code>&#39;s <code>moved</code> check, and removing <code>AddItem</code>&#39;s <code>fresh</code> check. The TUI repro needs no test harness — TileSelect&#39;s <code>r</code> emits <code>SetAxis</code> with no comparison against the current axis.</p>
<h3 id="naming-a-measure-at-the-bottom-right-leaves-the-cursor-put"><a class="anchor" href="#naming-a-measure-at-the-bottom-right-leaves-the-cursor-put">#</a>naming_a_measure_at_the_bottom_right_leaves_the_cursor_put</h3>
<p>A regression this PR introduced and review caught. <code>AddItem</code> gained a <code>reset_cursor</code>, and <code>CommitAndAdvance{Down}</code> on the trailing <code>+</code> column routes <code>RecordCol::NewMeasure</code> through <code>add_measure</code> → <code>effect::AddItem</code>. <code>inserts_a_column</code> matches only <code>NewCategory</code>, so <code>NewMeasure</code> falls through to the advance — and <code>EnterAdvance</code> at the bottom-right returns <code>AbortChain</code>, emitting no <code>SetSelected</code>. Nothing restored the cursor. The <code>SetSelected</code> repair added for <code>+cat</code> covered that arm only.</p>
<p>The fix moves the restore <strong>before</strong> the advance, which is gotcha 21&#39;s rule (tidy-up goes where an abort cannot reach it) applied to the cursor rather than to the mode. Injecting the old ordering fails it: <code>left: (0, 0), right: (0, 3)</code>.</p>
<h3 id="a-refused-model-command-invalidates-no-cursor"><a class="anchor" href="#a-refused-model-command-invalidates-no-cursor">#</a>a_refused_model_command_invalidates_no_cursor</h3>
<p>The second negative arm, and the one the first version of <a class="tid" href="../tickets.html#improvise-9mo9" title="[bug] Geometry effects that renumber the grid but never reset the cursor">improvise-9mo9</a>&#39;s fix got wrong. <strong>It was a loop of one until review</strong>: it named a class and tested <code>AddItem</code> alone, and injecting <code>reset_cursor()</code> into <code>AddCategory</code>&#39;s <code>Err</code> arm — the very bug it was written for, one effect over — left it green. It is now a table over all three refusable effects, and that injection fails it by name. <code>AddItem</code> and <code>AddItemInGroup</code> report a failure — an unknown category — through <code>status_msg</code>, and the reset was written <em>after</em> the <code>if let Err</code>, so a refused add bumped <code>cursor_epoch</code> and broadcast <code>Command::ResetCursor</code> to every client for a change the document never made. <code>AddCategory</code> had been gated on success in the same commit, which is what made the inconsistency visible.</p>
<p><code>SetNumberFormat</code>&#39;s arm cannot see this: it tests a command that <em>succeeds</em> and reshapes nothing, where this tests one that reshapes nothing because it was <em>refused</em>. Injecting the fault — moving the reset back outside the match — fails it on the bystander&#39;s cursor. The first assertion is the non-vacuity guard: if the category ever became valid, the case would stop being about a refusal and the test would say so rather than pass.</p>
<h4 id="helper-grid-shape"><a class="anchor" href="#helper-grid-shape">#</a>Helper — <code>grid_shape</code></h4>
<p>Not a test. The <code>row_items</code>/<code>col_items</code> of the layout, as text: which cell each position names. It is compared before and after rather than counted, because a <code>cats</code> change can re-identify every position without changing how many there are.</p>
<h3 id="a-clients-view-only-command-moves-only-that-clients-mirror"><a class="anchor" href="#a-clients-view-only-command-moves-only-that-clients-mirror">#</a>a_clients_view_only_command_moves_only_that_clients_mirror</h3>
<p>The other half of the door: a command that is nobody else&#39;s business must stay that way. A client&#39;s scroll must not move the authoritative <code>App</code>&#39;s own view slice — the model is shared, the UI is not.</p>
<h3 id="a-document-swap-reseeds-every-session"><a class="anchor" href="#a-document-swap-reseeds-every-session">#</a>a_document_swap_reseeds_every_session</h3>
<p>Replacing the document must clear the cursor map, because the incoming document&#39;s view names have nothing to do with the outgoing one&#39;s (gotcha 24). <code>install_workbook</code> does that for the <code>App</code>&#39;s own view slice, but nothing touched the session mirrors: a connected client went on naming the old document&#39;s view and scrolling by the old document&#39;s cursor, so it was projected a window past the end of a document it had never seen — an empty grid, with nothing that would ever bring it back.</p>
<h4 id="helper-other-document"><a class="anchor" href="#helper-other-document">#</a>Helper — <code>other_document</code></h4>
<p>Not a test — that test&#39;s incoming document, and the one whose rename hides a bug.</p>
<p>A different document, with a differently-named active view, so a mirror still keyed to the outgoing one is visible rather than coincidentally right.</p>
<h3 id="rebase-hands-every-session-a-snapshot-of-the-new-document"><a class="anchor" href="#rebase-hands-every-session-a-snapshot-of-the-new-document">#</a>rebase_hands_every_session_a_snapshot_of_the_new_document</h3>
<p>The reseed is reached from the projection path itself, not from a hook a host must remember to call — <code>Sessions</code> is not reachable from the effect that installs a workbook, so a convention there would be unenforceable. <code>rebase</code> exists for the other half: the client&#39;s <em>own</em> view slice lives on the client, and only a fresh <code>Snapshot</code> resets it.</p>
<h3 id="a-document-swap-over-the-wire-rebases-every-client"><a class="anchor" href="#a-document-swap-over-the-wire-rebases-every-client">#</a>a_document_swap_over_the_wire_rebases_every_client</h3>
<p>A document swap asked for by one client must reach every <em>other</em> client&#39;s own view slice, and the host must not be able to miss it.</p>
<p><code>Command::Model</code> put the <code>WholeWorkbook</code> mutations on the wire (<a class="tid" href="../tickets.html#improvise-kmgs" title="Derive the model half of the Command vocabulary from the EffectKind::Model effects">improvise-kmgs</a>), so <code>LoadModel</code> is now something another client can ask for. The server stayed honest — the generation bump reseeds every mirror on the next <code>project</code> — but a bystander went on holding <code>cursors[&quot;Default&quot;] = row_offset 3</code> from a document that no longer exists, and <code>Applied { dirty, broadcast }</code> said nothing that would tell its host to send a <code>Snapshot</code>. <code>Sessions::rebase</code>, the only thing that produces one, had no non-test caller in the tree.</p>
<p>That is gotcha 24&#39;s cursor-vs-geometry mismatch, arriving through another client&#39;s command — the same shape <code>doctrine(lockstep_binds_every_session_not_the_asker)</code> names, one layer out: the obligation was addressed to a host in prose instead of being carried by the value it gets back.</p>
<p>The server&#39;s own mirror self-heals off the generation bump (<code>a_document_swap_reseeds_every_session</code> covers that half); this is the other one, the client&#39;s slice, which only a <code>Snapshot</code> resets.</p>
<h4 id="helper-a-document-shaped-differently-under-the-same-view-name"><a class="anchor" href="#helper-a-document-shaped-differently-under-the-same-view-name">#</a>Helper — <code>a_document_shaped_differently_under_the_same_view_name</code></h4>
<p>Not a test — the fixture that test needs instead of <code>other_document</code>.</p>
<p>A different document whose active view has the <em>same name</em> as the outgoing one, and a different shape: one row where the outgoing document had three.</p>
<p><code>other_document</code> renames the active view, and that rename is what makes the bug below invisible: <code>apply_model</code> broadcasts a <code>SwitchView</code> when the name moves, a client folds it, and the stale cursor is then read under a key nothing has written yet — right by accident. Same name, different geometry is the case with nothing to hide behind, and it is the ordinary one: two revisions of a document both call their first view <code>Default</code>. (Gotcha 39&#39;s fixture warning.)</p>
<h4 id="helper-app-over-a-loadable-document"><a class="anchor" href="#helper-app-over-a-loadable-document">#</a>Helper — <code>app_over_a_loadable_document</code></h4>
<p>Not a test — the <code>App</code> that makes a wire <code>LoadModel</code> in that test really replace the document.</p>
<p>An <code>App</code> whose storage already holds that document, so a <code>Model(LoadModel)</code> arriving over the wire really does replace the document rather than setting an error status.</p>
<h3 id="a-command-that-leaves-the-document-in-place-is-not-a-rebase"><a class="anchor" href="#a-command-that-leaves-the-document-in-place-is-not-a-rebase">#</a>a_command_that_leaves_the_document_in_place_is_not_a_rebase</h3>
<p>The other arm of the same rule, so the check is not one that always says &quot;rebase&quot; (<code>doctrine(one_arm_of_a_symmetric_rule_is_evidence_about_that_arm_only)</code>). A model command that mutates the document in place, and a view-only one, are both diffs — the question <code>apply_model</code> asks is whether the generation moved, not which variant arrived.</p>
<h3 id="ui-project-tests-a-load-that-fails-is-not-a-rebase"><a class="anchor" href="#ui-project-tests-a-load-that-fails-is-not-a-rebase">#</a>ui::project::tests::a_load_that_fails_is_not_a_rebase</h3>
<p>Qualified because <code>ui::host::tests</code> has a test of the same name: this is the <code>apply_model</code> one, and a bare leaf was satisfiable by either.</p>
<p>A <code>LoadModel</code> naming a document the storage does not hold leaves the document alone, so it is a diff too. This is why <code>apply_model</code> asks the generation rather than the variant: &quot;is this one of the three <code>WholeWorkbook</code> commands&quot; is not the same question as &quot;was the document replaced&quot;, and a client would be reseeded onto the document it was already holding, losing its cursors to a failed load.</p>
<h4 id="note-the-cell-identity-module"><a class="anchor" href="#note-the-cell-identity-module">#</a>Note — the <code>cell_identity</code> module</h4>
<p>Emitting and resolving the surrogate a client names a cell by (ADR 002). The two halves are tested together on purpose: an id that is emitted and never resolved, or resolved by re-deriving it the same way it was emitted, would agree with itself while agreeing with nothing else.</p>
<h3 id="the-two-measures-of-a-row-share-a-record-and-differ-by-measure"><a class="anchor" href="#the-two-measures-of-a-row-share-a-record-and-differ-by-measure">#</a>the_two_measures_of_a_row_share_a_record_and_differ_by_measure</h3>
<p>Gotcha 1&#39;s unit, projected: two measure columns of one row are one record and two cells. Both halves matter — a surrogate that also varied by measure would break <code>record_versions</code>, and one that did not vary by measure at all would make the two columns the same cell.</p>
<h3 id="a-projected-record-id-resolves-to-the-cell-it-was-projected-for"><a class="anchor" href="#a-projected-record-id-resolves-to-the-cell-it-was-projected-for">#</a>a_projected_record_id_resolves_to_the_cell_it_was_projected_for</h3>
<p>The round trip that makes the surrogate usable: what the server projected for a position resolves back to the key that position has. Every row is checked, because one row cannot tell &quot;resolves correctly&quot; from &quot;resolves to the first record&quot;.</p>
<h4 id="helper-raw-app"><a class="anchor" href="#helper-raw-app">#</a>Helper — <code>raw_app</code></h4>
<p>Not a test — the fixture for every stored-cell surrogate test in <code>cell_identity</code>.</p>
<p><code>Region</code> on Row, <code>_Measure</code> on Column. Nothing regular sits on None, so every cell is a stored cell. Three rows and two measure columns, so &quot;the record&quot; cannot be confused with &quot;the row&quot; or with &quot;the cell&quot;.</p>
<h3 id="a-projected-aggregate-id-resolves-to-the-same-constraint"><a class="anchor" href="#a-projected-aggregate-id-resolves-to-the-same-constraint">#</a>a_projected_aggregate_id_resolves_to_the_same_constraint</h3>
<p>An aggregate names a coordinate <em>constraint</em>, and it must come back as the same constraint — including the page and filter coordinates baked into the layout&#39;s key (gotcha 27), which is why this resolves against <code>cell_key</code> rather than against the row and column labels.</p>
<h4 id="helper-aggregated-app"><a class="anchor" href="#helper-aggregated-app">#</a>Helper — <code>aggregated_app</code></h4>
<p>Not a test — the fixture for the aggregate half of <code>cell_identity</code>.</p>
<p>The same, with a second <em>regular</em> category left on None, so every visible cell sums over <code>Product</code> rather than being one stored cell. That is the fork <code>EditOrDrill</code> makes, and the one the wire projects.</p>
<h3 id="a-surrogate-for-a-removed-row-fails-to-resolve-and-the-others-do-not"><a class="anchor" href="#a-surrogate-for-a-removed-row-fails-to-resolve-and-the-others-do-not">#</a>a_surrogate_for_a_removed_row_fails_to_resolve_and_the_others_do_not</h3>
<p>The first arm of staleness. The identifiers are held constant and only the thing that makes the id stale is varied (<code>doctrine(a_rename_can_hide_a_stale_key)</code>): &quot;East&quot; is removed, so its surrogate must fail to resolve — while &quot;North&quot;&#39;s, taken from the same projection, still resolves to the same cell. Without that second assertion a <code>resolve_cell</code> that returned <code>None</code> for everything would pass.</p>
<h3 id="an-empty-cell-can-be-written-through-its-projected-id"><a class="anchor" href="#an-empty-cell-can-be-written-through-its-projected-id">#</a>an_empty_cell_can_be_written_through_its_projected_id</h3>
<p><a class="tid" href="../tickets.html#improvise-1ey" title="Browser frontend MVP: end-to-end working demo">improvise-1ey</a> is &quot;type a number into a cell and see the value update&quot;, and the cell a user types into is usually <strong>empty</strong> — so it is named by the grid and by nothing in the store. The index cannot answer it, which is why resolution falls back to the layout, and this is the case that makes that fallback load-bearing rather than defensive.</p>
<h3 id="a-write-by-surrogate-lands-on-exactly-that-cell"><a class="anchor" href="#a-write-by-surrogate-lands-on-exactly-that-cell">#</a>a_write_by_surrogate_lands_on_exactly_that_cell</h3>
<p>Two rows and two measure columns, so a write that went to &quot;the first cell&quot;, &quot;the whole row&quot; or &quot;the whole column&quot; is visible.</p>
<h3 id="a-write-through-a-dead-surrogate-changes-nothing"><a class="anchor" href="#a-write-through-a-dead-surrogate-changes-nothing">#</a>a_write_through_a_dead_surrogate_changes_nothing</h3>
<p>The second half of &quot;reject, nothing to write to&quot;: a surrogate that does not resolve must apply <em>nothing</em>, not fall back to some other cell. The document is compared whole, because a write to the wrong place is exactly the failure being excluded.</p>
<p>It goes through <code>model_effects</code> rather than the wire form, because the session door reaches the same conclusion first and typed — <code>Checked::against</code> refuses this command with <code>Rejection::RecordGone</code>, so a <code>Checked</code> over it does not exist. What is asserted here is the <strong>floor underneath</strong> that door: the key path and any host resolving a command locally get an empty batch rather than a misdirected write.</p>
<h3 id="a-records-mode-cell-is-named-by-its-coordinates-not-by-its-position"><a class="anchor" href="#a-records-mode-cell-is-named-by-its-coordinates-not-by-its-position">#</a>a_records_mode_cell_is_named_by_its_coordinates_not_by_its_position</h3>
<p>The trap gotcha 1 sets. <code>GridLayout::cell_key</code> returns a synthetic <code>(_Index, _Dim)</code> key here, carrying <em>positions</em> — the very thing ADR 002 rejected <code>_Index</code> for, because position names a different record after a filter, an insert or a delete. The projected id must come from <code>RecordRow::coords</code> instead, which is what resolving it back to the real cell proves.</p>
<h4 id="helper-records-app"><a class="anchor" href="#helper-records-app">#</a>Helper — <code>records_app</code></h4>
<p>Not a test — the fixture for every records-mode surrogate test.</p>
<p>Wide format: one row per coordinate tuple, a column per raw measure, the coordinate columns in front and a <code>+</code> column behind. Two records and two measures, so neither &quot;the row&quot; nor &quot;the column&quot; can stand in for the record by accident.</p>
<h3 id="the-measure-columns-of-a-record-row-share-its-record"><a class="anchor" href="#the-measure-columns-of-a-record-row-share-its-record">#</a>the_measure_columns_of_a_record_row_share_its_record</h3>
<p>Two measure columns of one row are two cells of one record, and the next row is a different record — the same shape the pivot case has, checked here because records mode reaches it by a different path.</p>
<h3 id="a-coordinate-column-is-projected-with-no-identity"><a class="anchor" href="#a-coordinate-column-is-projected-with-no-identity">#</a>a_coordinate_column_is_projected_with_no_identity</h3>
<p>A coordinate column is not a cell to write into: editing it renames that coordinate across <em>every</em> measure cell in the row (gotcha 1), which is a different intent from <code>SetCell</code>. So it is projected with no id at all, and a client cannot compose a write against it.</p>
<h3 id="a-write-moves-the-version-under-the-clients-own-surrogate"><a class="anchor" href="#a-write-moves-the-version-under-the-clients-own-surrogate">#</a>a_write_moves_the_version_under_the_clients_own_surrogate</h3>
<p>The version counter is keyed by the same surrogate the client holds, which is what lets one identity answer both ADR 002&#39;s question and ADR 001&#39;s. A write must move the counter the client would check.</p>
<h4 id="helper-apply-caught-up"><a class="anchor" href="#helper-apply-caught-up">#</a>Helper — <code>apply_caught_up</code></h4>
<p>Not a test — and note it is <code>#[cfg(test)] fn</code>, not <code>#[test] fn</code>.</p>
<p><code>Sessions::apply</code> for a client that is fully caught up, for the tests whose subject is not the basis. The staleness tests build the <code>Basis</code> by hand, so neither kind can pass by accident through the other&#39;s convenience.</p>
<hr />
<h4 id="note-the-basis-check-module"><a class="anchor" href="#note-the-basis-check-module">#</a>Note — the <code>basis_check</code> module</h4>
<p>ADR 001&#39;s basis check: a batch composed against a picture of the document that has since moved is refused, and one composed against the current picture is not (<a class="tid" href="../tickets.html#improvise-xwi6" title="Concurrent edits: basis check (ADR 001, layer 1 — the correctness floor)">improvise-xwi6</a>, <a class="tid" href="../tickets.html#improvise-fkfg" title="ADR-001 b: ModelCommand carries its basis; Applied gains a rejected case">improvise-fkfg</a>).</p>
<p><strong>Both arms, per tier.</strong> A check nobody has watched <em>reject</em> is indistinguishable from one that always accepts, and a check nobody has watched <em>accept</em> is indistinguishable from one that always rejects (<code>doctrine(check_the_checker)</code>). So every tier appears twice.</p>
<p>The fixtures hold every identifier constant and vary only what makes the basis stale (<code>doctrine(a_rename_can_hide_a_stale_key)</code>): the record a batch names is projected once and reused, so a rejection can never be an artifact of the test naming something different the second time.</p>
<h3 id="a-cell-edit-is-rejected-when-the-layout-moved-under-it"><a class="anchor" href="#a-cell-edit-is-rejected-when-the-layout-moved-under-it">#</a>a_cell_edit_is_rejected_when_the_layout_moved_under_it</h3>
<p><strong>The class-2 case, and the one test to keep if only one survives.</strong></p>
<p>A <code>CellData</code> batch is stale when the <em>layout</em> moved, even though no record it names was written: the coordinates its key hangs off changed underneath it. This is A removing an item while B commits an edit keyed by it, and it is corruption rather than a lost keystroke — which is exactly what a cell-level lock would not have covered, because holding a cell does not stop anyone deleting the row.</p>
<h4 id="helper-basis-for"><a class="anchor" href="#helper-basis-for">#</a>Helper — <code>basis_for</code></h4>
<p>Not a test — the claim every <code>basis_check</code> test moves exactly one tier of.</p>
<p>The basis a caught-up client holds for one record. Built by hand rather than through <code>caught_up</code> so each test can move exactly one tier of it and leave the rest true.</p>
<h3 id="a-cell-edit-composed-after-the-reshape-is-applied"><a class="anchor" href="#a-cell-edit-composed-after-the-reshape-is-applied">#</a>a_cell_edit_composed_after_the_reshape_is_applied</h3>
<p>The other arm of the same tier. A batch composed after the reshape is applied — so the rule is &quot;the layout moved since you looked&quot;, not &quot;the layout has ever moved&quot;.</p>
<h3 id="a-write-is-rejected-when-the-record-it-names-moved"><a class="anchor" href="#a-write-is-rejected-when-the-record-it-names-moved">#</a>a_write_is_rejected_when_the_record_it_names_moved</h3>
<p>Class 1: somebody wrote the record since this client read it. <strong>Data</strong> staleness — the record still resolves, so force is coherent and the reason says so. The identifiers do not move, only the value, so a rejection cannot be a renamed key in disguise.</p>
<h3 id="a-write-survives-another-record-moving"><a class="anchor" href="#a-write-survives-another-record-moving">#</a>a_write_survives_another_record_moving</h3>
<p>The arm that keeps the tier from being &quot;reject whenever anything was written&quot;. A record moving does not invalidate a batch that names a <em>different</em> one — which is the whole reason the tier is per-record rather than a single counter.</p>
<h3 id="a-write-to-a-record-that-no-longer-exists-says-so"><a class="anchor" href="#a-write-to-a-record-that-no-longer-exists-says-so">#</a>a_write_to_a_record_that_no_longer_exists_says_so</h3>
<p>Class 2 by the other route, and the reason ADR 002 put a <em>maintained</em> index in <code>DataStore</code> rather than regenerating one from the layout: &quot;the record is gone&quot; and &quot;the record changed&quot; must not collapse into one answer. There is nothing to write to here, so nothing to force — force never creates structure.</p>
<p>The test brings the layout claim up to date so that, whatever kind <code>RemoveItem</code> wears, only the record tier is left to answer. It is <code>CellData</code> today — removing an item drops its cells and moves no view&#39;s axes — so that line is currently a no-op; it is kept because a reclassification to <code>LayoutGeometry</code> would otherwise reject on the tier above and hide the case this test is about, silently turning it into a second copy of <code>a_cell_edit_is_rejected_when_the_layout_moved_under_it</code>.</p>
<h3 id="a-claim-that-omits-the-record-it-writes-is-not-a-free-pass"><a class="anchor" href="#a-claim-that-omits-the-record-it-writes-is-not-a-free-pass">#</a>a_claim_that_omits_the_record_it_writes_is_not_a_free_pass</h3>
<p>The force-by-omission hole, closed. Which records are checked is the <strong>server&#39;s</strong> decision, read off the command, so a client that simply left its record out of the claim does not skip the tier — it is told it never stated one. Without this, ADR 001&#39;s &quot;force is a re-composition, not a bypass&quot; would have a bypass sitting in the message format.</p>
<h3 id="a-document-swap-invalidates-every-claim"><a class="anchor" href="#a-document-swap-invalidates-every-claim">#</a>a_document_swap_invalidates_every_claim</h3>
<p>The top tier invalidates everything, whatever the other two say. It is checked first so the answer names the largest thing that moved: a client told &quot;a record moved&quot; after a document swap would go looking for a record that is not there.</p>
<h3 id="a-rejected-batch-changes-nothing-anywhere"><a class="anchor" href="#a-rejected-batch-changes-nothing-anywhere">#</a>a_rejected_batch_changes_nothing_anywhere</h3>
<p>Rejection is a <em>normal outcome</em>: nothing is applied, nobody else hears about it, and no basis moves. A rejection that dirtied something would tell every other client to redraw for a change that did not happen.</p>
<h3 id="a-published-basis-follows-the-grid-updates-it-describes"><a class="anchor" href="#a-published-basis-follows-the-grid-updates-it-describes">#</a>a_published_basis_follows_the_grid_updates_it_describes</h3>
<p>The order <code>ClientSession::basis_update</code> claims is load-bearing, pinned.</p>
<p>The basis names record counters for the cells the grid updates in the same batch carry, so a <code>Projection::Basis</code> that overtook them would let a client claim a version for a value it had not been shown — the mirror image of gotcha 33&#39;s rule that labels precede the cells they frame, and unlike that rule it had nothing holding it but the order of two statements in <code>project_diff</code>. Proved by injecting the swap (gotcha 43).</p>
<h3 id="a-client-that-folds-the-stream-composes-an-accepted-batch"><a class="anchor" href="#a-client-that-folds-the-stream-composes-an-accepted-batch">#</a>a_client_that_folds_the_stream_composes_an_accepted_batch</h3>
<p>The check is only satisfiable because the server <em>publishes</em> the basis — which ADR 001 does not mention, and without which a client could only forge one. So: a client that folds what it is sent can compose a batch that is accepted, end to end, with no test-only constructor anywhere in the path.</p>
<h3 id="re-composing-against-the-republished-basis-is-accepted"><a class="anchor" href="#re-composing-against-the-republished-basis-is-accepted">#</a>re_composing_against_the_republished_basis_is_accepted</h3>
<p>And the other side of publication: after somebody else writes, the stream carries a <em>new</em> basis, so the client that folds it can re-compose and be accepted. That is ADR 001&#39;s force — a re-composition against the current basis — reachable without any mechanism that skips the check.</p>
<h2 id="ui-host-rs"><a class="anchor" href="#ui-host-rs">#</a><code>ui/host.rs</code></h2>
<h3 id="connecting-hands-that-client-a-snapshot-that-draws-the-document"><a class="anchor" href="#connecting-hands-that-client-a-snapshot-that-draws-the-document">#</a>connecting_hands_that_client_a_snapshot_that_draws_the_document</h3>
<p>AC 2: the bootstrap is sent on connect, addressed to the new client, and it is not empty — a <code>Snapshot</code> whose seed carried nothing would satisfy &quot;a snapshot was sent&quot; while leaving the client blank.</p>
<h3 id="the-reported-extent-bounds-the-snapshot"><a class="anchor" href="#the-reported-extent-bounds-the-snapshot">#</a>the_reported_extent_bounds_the_snapshot</h3>
<p>The extent is what the snapshot is built at, so a client that says it can see one row is not sent two. This is the property that makes <code>Viewport</code> the handshake rather than an afterthought.</p>
<h3 id="without-the-command-the-cell-is-empty"><a class="anchor" href="#without-the-command-the-cell-is-empty">#</a>without_the_command_the_cell_is_empty</h3>
<p><code>doctrine(check_the_checker)</code>: the preceding assertion would also pass if the seed happened to contain the value already. Inject the fault — never send the command — and it must fail.</p>
<h3 id="a-bystander-is-projected-another-clients-edit"><a class="anchor" href="#a-bystander-is-projected-another-clients-edit">#</a>a_bystander_is_projected_another_clients_edit</h3>
<p>A cell edit is the whole document&#39;s, so a client that did not ask for it is told about it too. The projection is per-session because the viewports differ, not because the change does.</p>
<h4 id="helper-model"><a class="anchor" href="#helper-model">#</a>Helper — <code>model</code></h4>
<p>Not a test — and note it is <code>#[cfg(test)] fn</code>, not <code>#[test] fn</code>.</p>
<p>A model command from a client that is fully caught up, for the tests whose subject is not ADR 001&#39;s basis. It goes through <code>handle</code> like any other frame; only the basis is supplied rather than folded, and <code>caught_up</code> can do that because it holds the <code>App</code> (a client cannot). The staleness tests build their own <code>Basis</code>.</p>
<h4 id="helper-addressed"><a class="anchor" href="#helper-addressed">#</a>Helper — <code>addressed</code></h4>
<p>Not a test — how every <code>host.rs</code> test names a cell the way a client would.</p>
<p>The same cell as a client names it: the opaque surrogate the server projected on <code>CellDisplay::id</code>, which is the only handle a client has (ADR 002). A <code>CellKey</code> is not constructible over there.</p>
<h3 id="a-scroll-reaches-only-the-client-that-scrolled"><a class="anchor" href="#a-scroll-reaches-only-the-client-that-scrolled">#</a>a_scroll_reaches_only_the_client_that_scrolled</h3>
<p>A view-only command reaches nobody else. The counterpart to the test above: if every command fanned out, that one would pass for the wrong reason.</p>
<h3 id="a-batch-of-commands-is-applied-whole-and-in-order"><a class="anchor" href="#a-batch-of-commands-is-applied-whole-and-in-order">#</a>a_batch_of_commands_is_applied_whole_and_in_order</h3>
<p>One keypress resolved on a client is up to three commands, and the host must take all of them (<a class="tid" href="../tickets.html#improvise-bpq2" title="Client-side key resolution: what must the projections carry?">improvise-bpq2</a>).</p>
<p>The fixture is the shape that actually arrives: a cursor moved past the bottom of a one-row window, so the selection <em>and</em> the offset that follows it travel together. The assertion is the offset, not the selection, because a host that applied only the first command would still look right on <code>selected</code> — the window is what goes wrong, and it goes wrong silently.</p>
<h3 id="a-batch-is-abandoned-at-the-first-rejection"><a class="anchor" href="#a-batch-is-abandoned-at-the-first-rejection">#</a>a_batch_is_abandoned_at_the_first_rejection</h3>
<p>A refused batch applies nothing <em>after</em> the refusal. The stronger half — nothing <em>before</em> it either — is <code>a_refused_batch_leaves_the_commands_before_it_unapplied</code>; this is kept because the two halves fail independently.</p>
<h3 id="a-refused-batch-leaves-the-commands-before-it-unapplied"><a class="anchor" href="#a-refused-batch-leaves-the-commands-before-it-unapplied">#</a>a_refused_batch_leaves_the_commands_before_it_unapplied</h3>
<p><a class="tid" href="../tickets.html#improvise-955n" title="[bug] Host::commands applies a batch partially before rejecting">improvise-955n</a> / <a class="tid" href="../tickets.html#improvise-08wb" title="[bug] A refused ClientFrame::Commands keeps the commands before the refusal">improvise-08wb</a>. <strong>The bug</strong>: ADR 001 is emphatic that a stale batch is never applied, <em>not even partially</em>, and <a class="tid" href="../tickets.html#improvise-ondj" title="[bug] reduce_full/apply_command apply a Command::Model without the basis check">improvise-ondj</a> went to the trouble of a type (<code>Checked</code>) to make the unchecked path unconstructible. But <code>Checked</code> is <strong>per command</strong>, and a <code>ClientFrame::Commands</code> is a sequence of them: <code>Host::commands</code> applied in order and abandoned at the first rejection, so a frame whose <em>second</em> command was refused had already applied its first. The guarantee was true of a single command and false of a batch.</p>
<p>The earlier command is a <strong>view</strong> command, which is not a concession: it is the shape the one real producer emits (<code>keys::commit</code> — one mutation among view commands), and it is still observable, because the host folds it into that client&#39;s mirror. A frame may carry more than one mutation; what it may not do is apply some of them and refuse the rest, which is clause 4 and open.</p>
<p><strong>A refusal test needs an accepting one</strong> (<code>doctrine(a_helper_that_builds_the_world_the_code_wants_tests_nothing)</code>: pair a refusal with an acceptance, or the refusal can pass by refusing everything). The second half runs the same leading <code>SetSelected</code> followed by a command whose basis is <em>current</em>, and requires both to land. Without it, a <code>SetSelected</code> that simply never worked would clear the first assertion and prove nothing at all.</p>
<p>Inside it, the stale claim is built from <code>caught_up</code> and then has a <em>single</em> tier bumped, so the refusal can only be about the tier that was moved — a hand-built basis that is stale in three ways at once cannot tell which check answered.</p>
<h3 id="a-later-write-in-a-chain-still-answers-for-its-own-record"><a class="anchor" href="#a-later-write-in-a-chain-still-answers-for-its-own-record">#</a>a_later_write_in_a_chain_still_answers_for_its_own_record</h3>
<p><a class="tid" href="../tickets.html#improvise-955n" title="[bug] Host::commands applies a batch partially before rejecting">improvise-955n</a>. <strong>The regression 90ea26e introduced, and the reason clause 2 cannot be applied to all three tiers.</strong></p>
<p>Hoisting the basis check to the frame hoisted <em>all</em> of it, so every mutation after the first was admitted with no record check at all. Two clients, one record: A composes <code>[SetCell(North), SetCell(South)]</code> against an honest current basis, B writes South, A&#39;s frame arrives — and A&#39;s write to South landed on top of B&#39;s <strong>silently</strong>, which is exactly the lost update ADR 001&#39;s record tier exists to prevent. At the base commit the second command was checked and the frame refused.</p>
<p><strong>The per-command check was doing two jobs and the commit noticed one.</strong> &quot;Is the client&#39;s picture of the <em>document</em> current&quot; is a frame-level fact and hoists correctly. &quot;Is the client&#39;s picture of <em>this record</em> current&quot; is not: which records are at stake is a property of the command, which is why <code>records_named</code> is an exhaustive match over <code>ModelCommand</code> (gotcha 43). The incapability argument that motivated clause 3 is true of records a frame <strong>creates</strong> and was over-generalised to records it merely <strong>writes</strong> — and those are the ones the tier is for.</p>
<p>Three arms, because a fix that refuses everything would pass the first alone and a fix that checks nothing would pass the last two.</p>
<p>In arm 2, B moves South with a <code>SetCell</code>, which is <code>CellData</code>: neither the document nor the layout tier moves, so only the record tier can discriminate — which is what makes this a test of the record tier and not of the hoist in general.</p>
<p><strong>Arm 3 — the same record twice in one chain.</strong> Legitimate and likely (two measures on one record is one record) and it works for a reason worth pinning rather than rediscovering: <strong>every check happens before any command is applied</strong>, so the second write is compared to the record&#39;s <em>pre-frame</em> counter, not to the one its own sibling just bumped. Were the checks interleaved with the applies, this frame would refuse itself with <code>RecordMoved</code>.</p>
<p><strong>Read this before closing clause 4.</strong> Interleaving is tempting there, because it would close the <em>silence</em>: <code>[RemoveItem(North), SetCell(North)]</code> under interleaving is refused <code>RecordGone</code> instead of silently half-applying, which is half of what clause 4 owes. So check-before-apply is not a free win — it buys the legitimate double-write and <strong>sells</strong> the loud failure on a broken chain, and clause 4 has to pay that back some other way. Measured rather than argued: injecting the interleaving fails exactly two tests, this one and <code>a_frame_whose_later_mutation_fails_is_not_yet_undone</code>, and the second failing <em>is</em> the trade. Arm 3 is what stops someone taking the trade without noticing they made it.</p>
<h3 id="a-data-dependent-chain-lands-in-order"><a class="anchor" href="#a-data-dependent-chain-lands-in-order">#</a>a_data_dependent_chain_lands_in_order</h3>
<p><strong>The capability the contract is for</strong>: a chain of data-dependent commands, applied in order, each seeing what the previous one left.</p>
<p><code>[AddItem(&quot;East&quot;), SetCell(the East record)]</code> is the archetype, and it is the case a per-command basis check <strong>cannot</strong> admit however it is ordered: a client cannot claim a counter for a record its own chain is about to create, because <code>BasisCache::claim</code> filters to records the cache has been told about. So the basis is answered once, on the first command that carries one, and the rest of the chain runs against what its predecessors left.</p>
<p>The second assertion is the one that earns the test. Asserting only that the item exists would pass against a host that applied the first command and dropped the second — which is exactly what the previous rule did, loudly, and what the pre-frame check did, silently.</p>
<h3 id="a-frame-whose-later-mutation-fails-is-not-yet-undone"><a class="anchor" href="#a-frame-whose-later-mutation-fails-is-not-yet-undone">#</a>a_frame_whose_later_mutation_fails_is_not_yet_undone</h3>
<p><strong>Characterization of the open hole (clause 4), not an endorsement.</strong></p>
<p>The contract says a frame is all-or-nothing. It is not yet: <code>App::apply_effects</code> aborts without undoing (gotcha 21 — an abort is not a rollback), so a chain whose later mutation fails leaves the earlier one standing. <code>[RemoveItem(&quot;North&quot;), SetCell(the North record)]</code> is the shape: the removal lands, the write then resolves to nothing, <code>model_effects</code> returns an <strong>empty</strong> batch, and the frame is reported accepted having applied half of itself. Both the partial application and the silence are open.</p>
<p><strong>This test did not pin that, and the way it failed is the repo&#39;s own signature defect sitting on the one test whose job is to fail loudly if anyone believes the hole is closed.</strong> It asserted that North was removed and that nothing was rejected — both true of any <em>wholly successful</em> frame. The independent reviewer retargeted the second command from the removed North to the live South, making the frame succeed completely, and the test still passed. It could not tell half-applied from fully-applied, so it would never have inverted when clause 4 landed; it would have gone on passing. The missing assertion is the one that names the hole: <strong>the second mutation did not land.</strong></p>
<p>It is unreachable from any client here — the only producer of a multi-command frame is <code>keys::commit</code>, which emits one mutation among view commands — which is what makes it safe to document rather than rush.</p>
<p><strong>This test must be inverted when clause 4 lands</strong>: all three assertions flip, because then nothing at all happens.</p>
<p>The frame claims North at its current counter, so it passes the check and fails at <em>apply</em> time — which is the only way to reach the hole. A frame refused by the check never applies anything and would be testing clause 2 instead.</p>
<h4 id="helper-frame-basis"><a class="anchor" href="#helper-frame-basis">#</a>Helper — <code>frame_basis</code></h4>
<p>Not a test — the frame-level claim that test and the 955n tests state.</p>
<p>The basis a caught-up client would state for a <strong>frame</strong> naming <code>regions</code>: the two document tiers plus each named record&#39;s current counter.</p>
<p><code>reduce::caught_up</code> cannot stand in for this, and the difference is exactly what a frame is: it builds the claim for <strong>one command</strong>, so a frame&#39;s records beyond the first are simply absent from it and come back <code>RecordUnstated</code>. A real client calls <code>BasisCache::claim</code> once with every record its frame names, which is what this reproduces.</p>
<p><strong>Name only records that exist.</strong> <code>claim</code> <em>omits</em> a record the cache has never heard of; this helper always includes one, so pointed at a record that is not there it fabricates <code>(hash, 0)</code> — a claim no real client could compose, and precisely the input that flips the create exemption in <code>CheckedBatch</code>. For a record the frame is <em>creating</em>, leave it out, which is what a client would do.</p>
<h3 id="an-accepted-switch-is-relayed-to-nobody"><a class="anchor" href="#an-accepted-switch-is-relayed-to-nobody">#</a>an_accepted_switch_is_relayed_to_nobody</h3>
<p>The successor to <code>a_bystander_is_sent_the_broadcast_and_the_asker_is_not</code>, which pinned the pre-<a class="tid" href="../tickets.html#improvise-jytm" title="Split active_view: document default vs per-client authoritative (improvise-g0f)">improvise-jytm</a> relay (Copilot, PR #10). An accepted switch is now owed to nobody: the asker folded it, and no other client&#39;s view moves.</p>
<p>It asserts the bystander gets <strong>no frame at all</strong>, not merely no <code>SwitchView</code>. A relay that vanished while the projection fan-out still repainted every client would pass the narrower check and still be pushing one client&#39;s view change at the rest.</p>
<h3 id="a-geometry-command-relays-the-cursor-reset-to-the-asker-too"><a class="anchor" href="#a-geometry-command-relays-the-cursor-reset-to-the-asker-too">#</a>a_geometry_command_relays_the_cursor_reset_to_the_asker_too</h3>
<p>The other audience, and the reason <code>Broadcast</code> carries one rather than the relay deciding. A geometry change invalidates every cursor into the document (<a class="tid" href="../tickets.html#improvise-gvnb" title="[bug] A geometry command resets the App cursor but not the session mirrors">improvise-gvnb</a>), and the asker&#39;s is not exempt: a <code>Command::Model</code> is the one kind a client cannot fold optimistically, so nothing has reset it locally and skipping it would leave the very client that asked positioned in a grid it no longer has.</p>
<h3 id="a-stale-batch-is-refused-to-the-asker-alone"><a class="anchor" href="#a-stale-batch-is-refused-to-the-asker-alone">#</a>a_stale_batch_is_refused_to_the_asker_alone</h3>
<p>A refused batch is answered to the <strong>asker and nobody else</strong> (ADR 001). Nothing happened, so there is nothing to tell the others — and the asker is told only which class of staleness it was, because it already holds what is current.</p>
<h3 id="folding-the-relay-puts-a-refused-asker-back"><a class="anchor" href="#folding-the-relay-puts-a-refused-asker-back">#</a>folding_the_relay_puts_a_refused_asker_back</h3>
<p>What <code>Broadcast::To</code> is <em>for</em>, asserted end to end through <code>Host</code> rather than through <code>Sessions</code>: a client that folded a view name the document refused is put back by the frame the host sends it, so the cursor its next grid is positioned by is read under a key the document has (gotcha 24).</p>
<p>The client slice is folded <strong>before</strong> the command is handed to the host, which is what makes the assertion mean anything: a test that never folded would see &quot;Default&quot; whatever the relay did. The bystander is asserted silent in the same test, because a <code>ToAll</code> correction would also put the asker back and would be wrong.</p>
<h3 id="a-document-swap-sends-every-client-a-fresh-snapshot"><a class="anchor" href="#a-document-swap-sends-every-client-a-fresh-snapshot">#</a>a_document_swap_sends_every_client_a_fresh_snapshot</h3>
<p>AC of gotcha 39: a command that replaced the document owes <em>every</em> client a fresh <code>Snapshot</code>, not a diff — including the client that did not ask.</p>
<h4 id="helper-host-over-a-loadable-document"><a class="anchor" href="#helper-host-over-a-loadable-document">#</a>Helper — <code>host_over_a_loadable_document</code></h4>
<p>Not a test — the host that test and <code>a_load_that_fails_is_not_a_rebase</code> drive.</p>
<p>A host whose document can be replaced out from under its clients: a second document sits in the storage backend under a name <code>LoadModel</code> can reach.</p>
<h3 id="a-client-can-ask-its-host-to-save"><a class="anchor" href="#a-client-can-ask-its-host-to-save">#</a>a_client_can_ask_its_host_to_save</h3>
<p><a class="tid" href="../tickets.html#improvise-368h" title="[bug] No client on any transport can ask its host to save">improvise-368h</a>. <strong>The bug</strong>: <code>effect::Save</code> is <code>EffectKind::ViewOnly</code> — correctly, since it writes <em>this host&#39;s</em> path — so the derivation that builds <code>ModelCommand</code> out of the <code>EffectKind::Model</code> effects could not reach it (gotcha 38). There was no save on the wire at all, so <strong>no client on any transport could cause its host to write</strong>: a websocket session edited the document in memory and the file on disk never moved unless a human was at a TUI that was not there.</p>
<p>The derivation was not wrong; it was complete over the mutations and saving is not one. So the variant is <em>added</em> rather than derived, and it carries no path: a client cannot see the host&#39;s storage, and which document a host is hosting is the host&#39;s own fact.</p>
<h3 id="a-save-with-nowhere-to-write-is-refused-and-told-only-to-the-asker"><a class="anchor" href="#a-save-with-nowhere-to-write-is-refused-and-told-only-to-the-asker">#</a>a_save_with_nowhere_to_write_is_refused_and_told_only_to_the_asker</h3>
<p><a class="tid" href="../tickets.html#improvise-368h" title="[bug] No client on any transport can ask its host to save">improvise-368h</a>: the &quot;what if there is no path&quot; half. Not a panic and not a silent no-op — the two answers the ticket ruled out — but the vocabulary that already exists for &quot;nothing happened and here is why&quot;, addressed to the asker alone (gotcha 39).</p>
<p>The second client is not decoration: a refusal that leaked to the bystanders would be telling them about a failure of somebody else&#39;s host-local request, and this asserts it does not.</p>
<h3 id="a-write-that-fails-is-refused-rather-than-reported-as-saved"><a class="anchor" href="#a-write-that-fails-is-refused-rather-than-reported-as-saved">#</a>a_write_that_fails_is_refused_rather_than_reported_as_saved</h3>
<p><a class="tid" href="../tickets.html#improvise-368h" title="[bug] No client on any transport can ask its host to save">improvise-368h</a>: a write that was attempted and failed must not be reported as a success. <code>Save</code> signals failure by aborting its batch (gotcha 21), and the batch here is exactly <code>[Save]</code>, so an abort is that failure and nothing else.</p>
<p>The fixture fails for a reason that is real rather than mocked: a <code>PhysicalFs</code> pointed at a directory that does not exist. It creates nothing, so the test leaves no trace on disk.</p>
<h3 id="every-frame-survives-a-json-round-trip"><a class="anchor" href="#every-frame-survives-a-json-round-trip">#</a>every_frame_survives_a_json_round_trip</h3>
<p>The wire is JSON to start with (the ticket&#39;s choice, kept), so both directions must survive a round trip through it — including the one point at which <code>ViewState</code> is serialized (gotcha 25).</p>
<p>The inbound half carries a payload-bearing variant on each side of the envelope (<code>doctrine(populate_before_roundtrip)</code>): a <code>Viewport</code> and a <code>Command</code> carrying a <code>CellValue::Text</code>, which is the arm the outbound half cannot reach — a text cell aggregates to nothing under a None-axis category, so it never becomes a <code>CellDisplay</code>.</p>
<p><em>Note:</em> <code>host.rs</code> also carries a second <code>a_load_that_fails_is_not_a_rebase</code> (<code>doctrine(check_the_checker)</code>: inject the fault the test targets — a load that does not happen — and the snapshot must not appear; a test that saw <code>Snapshot</code> on every command would pass <code>a_document_swap_sends_every_client_a_fresh_snapshot</code>). It shares its name with the <code>ui/project.rs</code> test of the same name, which is why there is one heading for both above.</p>
<hr />
<h2 id="ui-cat-tree-rs"><a class="anchor" href="#ui-cat-tree-rs">#</a><code>ui/cat_tree.rs</code></h2>
<h3 id="the-tree-carries-the-virtual-categories-and-measure-includes-formula-targets"><a class="anchor" href="#the-tree-carries-the-virtual-categories-and-measure-includes-formula-targets">#</a>the_tree_carries_the_virtual_categories_and_measure_includes_formula_targets</h3>
<p><a class="tid" href="../tickets.html#improvise-vjms" title="Decide whether a category panel shows the virtual categories">improvise-vjms</a>: the panel lists the virtual categories alongside the regular ones, and <code>_Measure</code>&#39;s items are <em>derived</em> — explicit items ∪ formula targets (gotcha 7). An implementation reading <code>category(&quot;_Measure&quot;).items</code> passes every other assertion in the module and silently drops <code>Profit</code>, which is the failure <a class="tid" href="../tickets.html#improvise-ete" title="Add Model/Category getter methods to absorb .unwrap().items/groups chains">improvise-ete</a> was closed for proposing.</p>
<hr />
<h2 id="command-keymap-rs"><a class="anchor" href="#command-keymap-rs">#</a><code>command/keymap.rs</code></h2>
<h3 id="f1-and-question-mark-both-open-help"><a class="anchor" href="#f1-and-question-mark-both-open-help">#</a>f1_and_question_mark_both_open_help</h3>
<p><a class="tid" href="../tickets.html#improvise-th7m" title="Dead F(1) binding: bound to bare &#39;enter-mode&#39; then immediately rebound">improvise-th7m</a>: <code>F1</code> was bound twice on consecutive lines — first to a bare <code>enter-mode</code> with no args, then to <code>enter-mode [&quot;help&quot;]</code>. The first was dead, because <code>Keymap::bind</code> <em>inserts</em> and the later binding wins; had it ever been the surviving one it would have failed <code>require_args</code> at dispatch and made <code>F1</code> do nothing.</p>
<p>The risk in deleting a line of a duplicated pair is deleting the wrong one, so this pins which binding <code>F1</code> is meant to carry: the same one <code>?</code> carries, which is what identified it as the intended survivor.</p>
<h3 id="every-bound-command-name-is-registered"><a class="anchor" href="#every-bound-command-name-is-registered">#</a>every_bound_command_name_is_registered</h3>
<p><a class="tid" href="../tickets.html#improvise-9cn" title="Introduce register_cmd! macro to prevent name drift">improvise-9cn</a>: <code>Keymap::dispatch</code> resolves a binding through <code>registry.interactive(name, args, ctx).ok()?</code>, so a binding is a silent no-op key in two ways, not one: the name may not be registered, <em>or</em> the args may not parse. Both walks live in this test.</p>
<p>The args half matters because <a class="tid" href="../tickets.html#improvise-s13" title="Delete mode-entry shim commands; use enter-mode with arg">improvise-s13</a> moved bindings out of the nullary class the name check covers: <code>enter-mode [&quot;export-prompt&quot;]</code> and <code>enter-mode [&quot;formula-edit&quot;]</code> carry a mode name that <code>parse_mode_name</code> must accept, and a typo like <code>&quot;export_prompt&quot;</code> compiles, registers, and produces a dead <code>Ctrl-e</code>. (Gotcha 15.)</p>
<p>It builds the context in the mode whose keymap it is walking, because a few parsers read <code>ctx.mode</code> — <code>commit-buffer item</code> resolves its target category off <code>AppMode::ItemAdd</code>.</p>
<h3 id="prefix-keymaps-are-flat"><a class="anchor" href="#prefix-keymaps-are-flat">#</a>prefix_keymaps_are_flat</h3>
<p><a class="tid" href="../tickets.html#improvise-jc8" title="Evict host-only state from ViewState: flip the transient-keymap Arc">improvise-jc8</a>: an armed prefix is stored as the key that opened it and resolved against the CURRENT MODE&#39;s keymap. That is only correct while prefixes are flat — a <code>Prefix</code> binding nested inside a sub-keymap would be resolved against the mode map, where it does not exist, and the second half of the sequence would silently do nothing. Adding a nested prefix must fail here rather than in the terminal.</p>
<h3 id="every-panel-keymap-shares-the-same-spine"><a class="anchor" href="#every-panel-keymap-shares-the-same-spine">#</a>every_panel_keymap_shares_the_same_spine</h3>
<p><a class="tid" href="../tickets.html#improvise-4pg" title="Extract panel mode keymap template">improvise-4pg</a>: the three panel keymaps were written out separately, so a spine binding could be changed in one and forgotten in the others. They share <code>PanelKeymap</code> now; this pins what &quot;the spine&quot; is, including that each panel&#39;s cursor keys move its <em>own</em> cursor. (Gotcha 12.)</p>
<h3 id="every-mode-with-panel-toggles-binds-all-of-them"><a class="anchor" href="#every-mode-with-panel-toggles-binds-all-of-them">#</a>every_mode_with_panel_toggles_binds_all_of_them</h3>
<p>Every panel&#39;s toggle key is bound in Normal and in all three panel modes, so focus can move between any two and the focused one closes. A fourth panel gets all four keymaps from one <code>Panel::ALL</code> entry.</p>
<h3 id="panel-toggle-keys-dispatch-to-real-effects"><a class="anchor" href="#panel-toggle-keys-dispatch-to-real-effects">#</a>panel_toggle_keys_dispatch_to_real_effects</h3>
<p><code>Panel::name()</code> has to spell what the registry&#39;s panel parser accepts. A lookup-only assertion would not catch a mismatch — <code>dispatch</code> swallows an unresolvable command with <code>.ok()?</code> — so the toggle key must come back with real effects.</p>
<h3 id="every-text-entry-mode-types-into-the-buffer-its-prompt-names"><a class="anchor" href="#every-text-entry-mode-types-into-the-buffer-its-prompt-names">#</a>every_text_entry_mode_types_into_the_buffer_its_prompt_names</h3>
<p><a class="tid" href="../tickets.html#improvise-k8h" title="Tie MinibufferConfig.buffer_key to the keymap&#39;s buffer name">improvise-k8h</a>: the buffer name used to be written on both sides of the crate — <code>AppMode</code>&#39;s <code>MinibufferConfig</code> names the buffer the prompt describes, and the keymap names the buffer <code>append-char</code> edits. If those disagree the mode prompts over one buffer while every keystroke edits another, with nothing to notice. <code>TextEntry</code> now reads the name off the mode, and this pins the result for every text-entry mode. (Gotcha 11.)</p>
<h4 id="helper-text-entry-modes-with-exits"><a class="anchor" href="#helper-text-entry-modes-with-exits">#</a>Helper — <code>text_entry_modes_with_exits</code></h4>
<p>Not a test — the list that test and the two below it walk.</p>
<p>The seven modes that carry a <code>MinibufferConfig</code>, each with the keys that commit or leave it. <code>SearchMode</code> is not among them: it is <code>Normal</code> plus a flag, so it has no <code>AppMode</code> variant and no minibuffer config to agree with.</p>
<h3 id="every-typing-keymap-belongs-to-a-text-entry-mode"><a class="anchor" href="#every-typing-keymap-belongs-to-a-text-entry-mode">#</a>every_typing_keymap_belongs_to_a_text_entry_mode</h3>
<p>The list in <code>text_entry_modes_with_exits</code> must stay complete: every keymap that types into a buffer belongs to a mode whose minibuffer names that same buffer. Only <code>SearchMode</code>, which has no <code>AppMode</code> variant, is exempt.</p>
<h3 id="text-entry-modes-clear-the-buffer-they-type-into"><a class="anchor" href="#text-entry-modes-clear-the-buffer-they-type-into">#</a>text_entry_modes_clear_the_buffer_they_type_into</h3>
<p>Every minibuffer mode types into exactly one buffer, and every key that commits or leaves must clear that same buffer on the way — a buffer left behind pre-fills the next entry with the last one. <code>SearchMode</code> is excluded on purpose: it has no commit and its query is owned by <code>exit-search-mode</code>, not by <code>clear-buffer</code>.</p>
<p>The buffer is derived from each mode rather than restated in the test — <code>every_text_entry_mode_types_into_the_buffer_its_prompt_names</code> owns pinning what it should be (<a class="tid" href="../tickets.html#improvise-k8h" title="Tie MinibufferConfig.buffer_key to the keymap&#39;s buffer name">improvise-k8h</a>).</p>
<h3 id="minibuffer-enter-dispatches-to-a-registered-commit"><a class="anchor" href="#minibuffer-enter-dispatches-to-a-registered-commit">#</a>minibuffer_enter_dispatches_to_a_registered_commit</h3>
<p><a class="tid" href="../tickets.html#improvise-6t3" title="Unify Commit{Formula,CategoryAdd,ItemAdd} into CommitBuffer(kind)">improvise-6t3</a>: the three commit commands collapsed into a single <code>commit-buffer &lt;kind&gt;</code>. <code>dispatch</code> swallows an unresolvable command name with <code>.ok()?</code>, so a lookup-only assertion would still pass had the rename missed the registry — Enter has to come back with real effects.</p>
<h3 id="dump-every-binding"><a class="anchor" href="#dump-every-binding">#</a>dump_every_binding</h3>
<p>The instrument <code>doctrine(binding_dump_diff)</code> asks for: every <code>(mode, key, modifier)</code> lookup across all the modes, written to the file named by <code>IMPROVISE_BINDING_DUMP</code>.</p>
<p>Most bindings have no direct test, so a green suite is not evidence that a keymap refactor preserved them. Dump before, dump after, diff. It is inert without the variable: a test that writes a file nobody asked for is a surprise, not a check.</p>
<p>This is what proved the keymap move to <code>improvise-protocol</code> changed nothing — 4021 resolved lookups, byte-identical either side — and the diff was shown to be discriminating by renaming one binding and watching eight lines move.</p>
<hr />
<h2 id="command-cmd-commit-rs"><a class="anchor" href="#command-cmd-commit-rs">#</a><code>command/cmd/commit.rs</code></h2>
<h3 id="invalid-formula-reports-the-parse-error-not-success"><a class="anchor" href="#invalid-formula-reports-the-parse-error-not-success">#</a>invalid_formula_reports_the_parse_error_not_success</h3>
<p><a class="tid" href="../tickets.html#improvise-6t3" title="Unify Commit{Formula,CategoryAdd,ItemAdd} into CommitBuffer(kind)">improvise-6t3</a>: the commit pushed <code>set_status(&quot;Formula added&quot;)</code> <em>after</em> <code>AddFormula</code>, so the parse error that <code>AddFormula::apply</code> writes to the status bar was immediately overwritten. An unparseable formula was dropped while the UI reported success.</p>
<p>The add effect is the only party that knows whether it worked, so it has to get the last word on the status line.</p>
<h3 id="commit-and-advance-threads-edit-mode-to-enter-edit-at-cursor"><a class="anchor" href="#commit-and-advance-threads-edit-mode-to-enter-edit-at-cursor">#</a>commit_and_advance_threads_edit_mode_to_enter_edit_at_cursor</h3>
<p><code>CommitAndAdvance</code> must thread its <code>edit_mode</code> through to the trailing <code>EnterEditAtCursor</code> effect so the post-commit re-edit lands in the mode the keymap requested. The command never reads <code>ctx.mode</code>.</p>
<h3 id="commit-save-writes-the-model-to-the-typed-path"><a class="anchor" href="#commit-save-writes-the-model-to-the-typed-path">#</a>commit_save_writes_the_model_to_the_typed_path</h3>
<p><a class="tid" href="../tickets.html#improvise-lae" title="ZZ / :wq with no file path should prompt for a path instead of only erroring">improvise-lae</a>: the save prompt has to commit to <code>SaveAs</code> and not to <code>ExportCsv</code>, which is what makes it a separate mode from <code>ExportPrompt</code> rather than a second binding onto it — <code>ExportCsv</code> would write CSV under whatever <code>.improv</code> name the user typed.</p>
<p><code>SaveAs</code> and not <code>Save</code>, because <code>Save</code> writes the path the document already has, and the whole reason this prompt exists is that there isn&#39;t one.</p>
<h3 id="commit-save-on-an-empty-path-writes-nothing"><a class="anchor" href="#commit-save-on-an-empty-path-writes-nothing">#</a>commit_save_on_an_empty_path_writes_nothing</h3>
<p>An empty prompt must not reach <code>SaveAs</code>: <code>SaveAs(PathBuf::from(&quot;&quot;))</code> asks the backend to write to <code>&quot;&quot;</code>, and the user then reads whatever I/O error that produces instead of &quot;you typed nothing&quot;.</p>
<h3 id="commit-export-bails-on-an-empty-path"><a class="anchor" href="#commit-export-bails-on-an-empty-path">#</a>commit_export_bails_on_an_empty_path</h3>
<p><a class="tid" href="../tickets.html#improvise-z05r" title="CommitExport does not bail on an empty path, and ExportCsv does not abort the batch">improvise-z05r</a>&#39;s first arm. <code>CommitExport</code> did not bail on an empty path where <code>CommitBuffer</code>&#39;s three kinds all do (gotcha 20: buffer to trim to bail-on-empty to dirty to status is the shared spine), so Enter on an empty export prompt asked the backend to write to <code>&quot;&quot;</code> and the user read whatever I/O error that produced.</p>
<p>The same change reorders the batch: the mode change now comes <strong>first</strong>, because <code>ExportCsv</code> aborts on a failed write as of z05r&#39;s other arm, and an abort at the tail would strand the user in <code>ExportPrompt</code> — where <code>draw_bottom_bar</code> renders the minibuffer <em>instead of</em> the status line, so the export error is never drawn at all and the operation appears to do nothing (gotcha 21). That ordering is asserted in <code>commit_export_produces_export_and_normal_mode</code>, by index and not by presence.</p>
<hr />
<h2 id="command-cmd-fit-rs"><a class="anchor" href="#command-cmd-fit-rs">#</a><code>command/cmd/fit.rs</code></h2>
<h3 id="the-command-installs-a-formula-the-document-evaluates"><a class="anchor" href="#the-command-installs-a-formula-the-document-evaluates">#</a>the_command_installs_a_formula_the_document_evaluates</h3>
<p>The end-to-end claim of <a class="tid" href="../tickets.html#improvise-vx3" title="fit-measure command: fit a tree and add it as a formula">improvise-vx3</a>, asserted against a real document: a command fits, the formula lands through the ordinary <code>add_formula</code> path, and the new measure evaluates to numbers.</p>
<h4 id="helper-workbook"><a class="anchor" href="#helper-workbook">#</a>Helper — <code>workbook</code></h4>
<p>Not a test — the document every <code>fit.rs</code> test fits against.</p>
<p>A document a tree can learn exactly — <code>Score</code> steps at <code>Cost</code> 50 — in the units a real document uses.</p>
<p><strong>Every category is a <code>Label</code></strong>, so the only feature is the numeric measure <code>Cost</code> and the champion cannot reach an unresolvable one. An earlier version left <code>Region</code> regular with one item and claimed <code>simplify</code> would prune the constant one-hot; it does not, because <code>build_categorical</code> records <code>(0.0, 1.0)</code> for every one-hot whatever the column holds — so these tests were passing on seed luck (Copilot on PR #25). See <code>improvise_gp::fit</code>&#39;s own fixture for the same correction.</p>
<h3 id="the-status-line-reports-quality-against-the-baseline"><a class="anchor" href="#the-status-line-reports-quality-against-the-baseline">#</a>the_status_line_reports_quality_against_the_baseline</h3>
<p><a class="tid" href="../tickets.html#improvise-vx3" title="fit-measure command: fit a tree and add it as a formula">improvise-vx3</a> is emphatic that the rule alone is not the deliverable: a tidy tree that generalizes no better than the mean is worse than useless. So the status line carries the held-out comparison, what the rule leans on, and the seed that reproduces it.</p>
<h3 id="a-refusal-names-what-went-wrong-and-stores-nothing"><a class="anchor" href="#a-refusal-names-what-went-wrong-and-stores-nothing">#</a>a_refusal_names_what_went_wrong_and_stores_nothing</h3>
<p>A refusal reaches the user <em>whole</em>. &quot;fit failed&quot; is not actionable; naming the measure that does not exist is (<a class="tid" href="../tickets.html#improvise-8xhy" title="[bug] A fit over a categorical feature emits an unevaluable formula">improvise-8xhy</a>&#39;s argument, arriving at the place a user meets it).</p>
<h3 id="the-seed-argument-changes-the-search"><a class="anchor" href="#the-seed-argument-changes-the-search">#</a>the_seed_argument_changes_the_search</h3>
<p>Gotcha 37b: the seed is half of what reproduces a fit, so it is an argument — and a different one really does search differently.</p>
<p>It asserts on the <strong>rule</strong>, never on the batch&#39;s debug string. The first version compared <code>effects_debug</code> over the whole vec, which was vacuous: <code>status_line</code> prints <code>seed {:#x}</code>, so the two strings differ because the seed was <em>printed</em>, and <code>fit_measure</code> could have ignored <code>request.seed</code> entirely with the test still green (<code>doctrine(check_the_checker)</code>).</p>
<p><code>AddFormula</code> has exactly two fields — the formula text and the target category — and the seed reaches neither, so its <code>Debug</code> cannot differ for the reason the batch&#39;s did. (A substring guard was tried and is wrong: the seeds <code>1</code> and <code>99</code> occur inside perfectly ordinary thresholds.)</p>
<h3 id="the-seed-the-status-line-prints-is-a-seed-this-command-accepts"><a class="anchor" href="#the-seed-the-status-line-prints-is-a-seed-this-command-accepts">#</a>the_seed_the_status_line_prints_is_a_seed_this_command_accepts</h3>
<p>Copilot on PR #25. The status line advertises the seed as <code>{:#x}</code> and tells the user that is what reproduces the fit, so the one instruction it gives has to work on the value it just showed them — a decimal-only parser made copying it back an error.</p>
<p>Asserted as a <em>round trip through the status line</em>, not against a hand-written <code>&quot;0x…&quot;</code>: a literal would still pass if <code>status_line</code> changed how it spells the number, which is the half that breaks.</p>
<hr />
<h2 id="command-cmd-grid-rs"><a class="anchor" href="#command-cmd-grid-rs">#</a><code>command/cmd/grid.rs</code></h2>
<h3 id="drill-into-formula-cell-returns-data-records"><a class="anchor" href="#drill-into-formula-cell-returns-data-records">#</a>drill_into_formula_cell_returns_data_records</h3>
<p>Drilling into a formula cell (<code>Profit = Revenue - Cost</code>) must return the underlying data records, not an empty result set. The formula target coordinate is stripped from the drill key so that <code>matching_cells</code> finds the raw data backing the formula (gotcha 10).</p>
<h3 id="drilling-fixes-its-coordinates-on-filter-not-page"><a class="anchor" href="#drilling-fixes-its-coordinates-on-filter-not-page">#</a>drilling_fixes_its_coordinates_on_filter_not_page</h3>
<p><a class="tid" href="../tickets.html#improvise-dz8" title="Add Axis::Filter for drill-fixed coordinates">improvise-dz8</a>: a drilled coordinate is a constraint of the drill, not a dimension the user may page through, so it lands on <code>Filter</code>. It used to land on <code>Page</code>, which put it in the tile bar and under <code>[</code>/<code>]</code> as if the user could move it (gotcha 27).</p>
<hr />
<h2 id="command-cmd-mode-rs"><a class="anchor" href="#command-cmd-mode-rs">#</a><code>command/cmd/mode.rs</code></h2>
<h3 id="enter-mode-export-prompt-sets-mode-and-clears-its-buffer"><a class="anchor" href="#enter-mode-export-prompt-sets-mode-and-clears-its-buffer">#</a>enter_mode_export_prompt_sets_mode_and_clears_its_buffer</h3>
<p>The <code>enter-export-prompt</code> shim is gone (<a class="tid" href="../tickets.html#improvise-s13" title="Delete mode-entry shim commands; use enter-mode with arg">improvise-s13</a>); the generic <code>enter-mode</code> carries the mode name instead, and clears the prompt&#39;s buffer on the way in.</p>
<h3 id="enter-mode-clears-a-search-keyed-minibuffer-through-write-buffer"><a class="anchor" href="#enter-mode-clears-a-search-keyed-minibuffer-through-write-buffer">#</a>enter_mode_clears_a_search_keyed_minibuffer_through_write_buffer</h3>
<p><a class="tid" href="../tickets.html#improvise-gdl" title="SetBuffer effect special-cases the &quot;search&quot; buffer name">improvise-gdl</a> made <code>write_buffer</code> the single accessor for a runtime-named buffer, because &quot;search&quot; is not an entry in the buffers map but its own <code>ViewState</code> field. <code>EnterMode</code> built a <code>SetBuffer</code> directly, so a mode whose <code>buffer_key</code> is &quot;search&quot; would clear a map entry nobody reads and leave the previous query on screen — the exact desync gdl closed. No shipped <code>AppMode</code> names that buffer yet, so the mode here is constructed by hand; that is what makes the test a demonstration rather than a restatement.</p>
<h3 id="edit-or-drill-passes-records-editing-mode-through"><a class="anchor" href="#edit-or-drill-passes-records-editing-mode-through">#</a>edit_or_drill_passes_records_editing_mode_through</h3>
<p><code>EditOrDrill</code> must trust its <code>edit_mode</code> parameter rather than checking <code>ctx.mode</code> — the records-normal keymap supplies <code>records-editing</code>, but the command itself never inspects the runtime mode. This is the parallel of the deleted <code>enter_edit_mode_produces_editing_mode</code> test for the records branch.</p>
<h3 id="enter-edit-at-cursor-cmd-passes-target-mode-to-effect"><a class="anchor" href="#enter-edit-at-cursor-cmd-passes-target-mode-to-effect">#</a>enter_edit_at_cursor_cmd_passes_target_mode_to_effect</h3>
<p><code>EnterEditAtCursorCmd</code> must hand its <code>target_mode</code> straight through to the <code>EnterEditAtCursor</code> effect — the keymap (records <code>o</code> sequence or commit-and-advance) decides; the command never inspects <code>ctx</code>. It fronts the pair with <code>RebuildLayout</code> so the cursor is read against a layout that includes whatever ran earlier in the sequence (<a class="tid" href="../tickets.html#improvise-ehz" title="EnterEditAtCursor effect calls rebuild_layout and builds a CmdContext inside apply">improvise-ehz</a>, gotcha 23).</p>
<hr />
<h2 id="command-cmd-navigation-rs"><a class="anchor" href="#command-cmd-navigation-rs">#</a><code>command/cmd/navigation.rs</code></h2>
<h3 id="page-cycling-skips-filter-coordinates"><a class="anchor" href="#page-cycling-skips-filter-coordinates">#</a>page_cycling_skips_filter_coordinates</h3>
<p><a class="tid" href="../tickets.html#improvise-dz8" title="Add Axis::Filter for drill-fixed coordinates">improvise-dz8</a>: <code>[</code> and <code>]</code> page through Page-axis categories. A coordinate the drill fixed sits on <code>Filter</code> and must be invisible to them, or the user could page out of the drill they are inside.</p>
<h3 id="enter-advance-at-bottom-right-emits-abort-chain"><a class="anchor" href="#enter-advance-at-bottom-right-emits-abort-chain">#</a>enter_advance_at_bottom_right_emits_abort_chain</h3>
<p>At bottom-right <code>EnterAdvance</code> has no place to go, so it emits a single <code>AbortChain</code> effect. Trailing effects in a <code>CommitAndAdvance</code> batch (<code>EnterEditAtCursor</code>) are then skipped, which is how &quot;Enter at bottom-right commits and exits editing&quot; is realised.</p>
<hr />
<h2 id="command-cmd-panel-rs"><a class="anchor" href="#command-cmd-panel-rs">#</a><code>command/cmd/panel.rs</code></h2>
<h3 id="cycle-panel-focus-advances-from-formula-to-category"><a class="anchor" href="#cycle-panel-focus-advances-from-formula-to-category">#</a>cycle_panel_focus_advances_from_formula_to_category</h3>
<p>Bug <a class="tid" href="../tickets.html#improvise-dqn" title="CyclePanelFocus does not actually cycle between panels">improvise-dqn</a>: <code>CyclePanelFocus</code> always picked the first open panel, ignoring the currently focused panel. With formula + category open and focus on the formula panel, cycling must move to the category panel.</p>
<h3 id="create-and-switch-view-names-incrementally"><a class="anchor" href="#create-and-switch-view-names-incrementally">#</a>create_and_switch_view_names_incrementally</h3>
<p><a class="tid" href="../tickets.html#improvise-aaw" title="Replace CreateAndSwitchView with Binding::Sequence">improvise-aaw</a> asked for this command to be replaced by a keymap <code>Binding::Sequence [create-view X, switch-view X, enter-mode normal]</code>. It cannot be: a <code>Sequence</code> carries <strong>literal</strong> args, and the name here is computed from <code>ctx</code> — so a Sequence would hard-code one name and the second <code>n</code> in the view panel would collide with the first.</p>
<p>The test was called <code>..._names_incrementally</code> and asserted only that <code>CreateView</code>/<code>SwitchView</code>/<code>Normal</code> appeared — it never looked at the name, which is the one thing that makes this command a command and not a composition. Now it does, at two different view counts, because a single count cannot tell &quot;derived from the workbook&quot; from &quot;constant&quot;.</p>
<hr />
<h2 id="command-cmd-registry-rs"><a class="anchor" href="#command-cmd-registry-rs">#</a><code>command/cmd/registry.rs</code></h2>
<h3 id="every-entry-constructs-a-command-with-its-registered-name"><a class="anchor" href="#every-entry-constructs-a-command-with-its-registered-name">#</a>every_entry_constructs_a_command_with_its_registered_name</h3>
<p><a class="tid" href="../tickets.html#improvise-9cn" title="Introduce register_cmd! macro to prevent name drift">improvise-9cn</a> / <a class="tid" href="../tickets.html#improvise-61f" title="Add invariant test for command registry name mapping">improvise-61f</a>: every registration derives its name from a prototype instance, so a typo in the name is impossible. What the types do <em>not</em> catch is the commands whose <code>name()</code> branches on a field — <code>ViewNavigate</code>, <code>TileAxisOp</code>, <code>ToggleGroupAtCursor</code>, <code>CommitAndAdvance</code>. Their prototype and the value their constructor builds are the same type, so registering <code>ViewNavigate { forward: true }</code> while building <code>forward: false</code> compiles cleanly and files the command under the wrong name, where dispatch silently misses. Constructing every entry both ways and comparing names is the only check that reaches that class. (Gotcha 15.)</p>
<p>Some entries are deliberately one-path — <code>commit-and-advance-right</code> reads the edit buffer and cursor, so its parse fn always errors — but every entry must be reachable by at least one path, or its name goes unchecked.</p>
<h3 id="registered-names-are-unique"><a class="anchor" href="#registered-names-are-unique">#</a>registered_names_are_unique</h3>
<p>A variant mix-up does not show up as a name mismatch — it shows up as a <strong>collision</strong>. Registering <code>ViewNavigate { forward: false }</code> where <code>forward: true</code> belongs files a second entry under &quot;view-back&quot; and removes &quot;view-forward&quot; from the registry entirely; <code>parse</code> takes the first match, so every surviving entry still agrees with its own name. Uniqueness is what catches it.</p>
<h3 id="the-context-reports-each-panels-own-open-flag"><a class="anchor" href="#the-context-reports-each-panels-own-open-flag">#</a>the_context_reports_each_panels_own_open_flag</h3>
<p><a class="tid" href="../tickets.html#improvise-y4xg" title="CmdContext carries a fourth Panel-to-field mapping, and it is completely unguarded">improvise-y4xg</a> and <a class="tid" href="../tickets.html#improvise-qsmp" title="App::cmd_context&#39;s panel-open propagator is untested: C cannot open a closed panel and nothing fails">improvise-qsmp</a>, two shapes of one defect. <code>registry.rs</code> mapped <code>Panel</code> to a <code>CmdContext</code> field in two places, and <code>App::cmd_context</code> copies the three flags out of <code>ViewState</code> in a third. <strong>Both were demonstrated unguarded</strong>: swapping Formula and Category at the two registry sites left the workspace at 1400 passing, and hardcoding the <code>category_panel_open</code> propagator to <code>true</code> left it at 1394.</p>
<p>Nothing exercised either, because <code>cmd/mod.rs::test_helpers</code> sets the panel flags explicitly on the context it builds — the command tests construct the value they then assert on, so no test ever drove the real propagator.</p>
<p>The registry mapping is gone: both sites call <code>CmdContext::panel_open(panel)</code>, the one mapping on this side of the wall. That leaves two permutable sites, and the test is aimed at both from a path neither controls — it writes <code>view_state</code>&#39;s three fields <strong>by name</strong> and reads back through the enum accessor on a context built by the real <code>App::cmd_context</code>. PR #70&#39;s review caught the weaker shape of this: a panel test that zeroed and read through the same four accessors passed a consistent permutation.</p>
<p>Injected fault: swap <code>Panel::Formula</code> and <code>Panel::Category</code> in <code>panel_open</code>. Fails. Injected fault: hardcode the <code>category_panel_open</code> propagator to <code>true</code>. Fails.</p>
<h3 id="toggle-panel-visibility-reads-the-panel-it-names"><a class="anchor" href="#toggle-panel-visibility-reads-the-panel-it-names">#</a>toggle_panel_visibility_reads_the_panel_it_names</h3>
<p>The behavioural half, and the one that reaches the registry. Its interactive arm passes <code>currently_open</code> straight through, so with exactly one panel open the command built for each of the three names carries its own panel&#39;s flag. Both injections above fail here too.</p>
<h3 id="a-focused-open-panel-closes-and-a-closed-one-opens"><a class="anchor" href="#a-focused-open-panel-closes-and-a-closed-one-opens">#</a>a_focused_open_panel_closes_and_a_closed_one_opens</h3>
<p><a class="tid" href="../tickets.html#improvise-qsmp" title="App::cmd_context&#39;s panel-open propagator is untested: C cannot open a closed panel and nothing fails">improvise-qsmp</a>&#39;s own scenario: <code>toggle-panel-and-focus</code> decides close only when the panel is <em>both</em> open and focused, so the case that separates a right propagator from a stuck-true one needs the mode set to the panel&#39;s own. With the flag stuck true, pressing a panel&#39;s key on a closed panel decides to <em>close</em> it and the panel becomes unopenable by its own key — which is what this test&#39;s second arm names.</p>
<h3 id="helper-open-only"><a class="anchor" href="#helper-open-only">#</a>Helper — <code>open_only</code></h3>
<p>Not a test. It spells <code>formula_panel_open</code>, <code>category_panel_open</code> and <code>view_panel_open</code> as fields rather than looping over <code>Panel::ALL</code> through an accessor, and that is the whole evidentiary value: a permutation in any mapping between the <code>Panel</code> enum and those three booleans has to disagree with this helper.</p>
<hr />
<h2 id="command-cmd-text-buffer-rs"><a class="anchor" href="#command-cmd-text-buffer-rs">#</a><code>command/cmd/text_buffer.rs</code></h2>
<h3 id="writing-the-search-buffer-targets-the-search-query"><a class="anchor" href="#writing-the-search-buffer-targets-the-search-query">#</a>writing_the_search_buffer_targets_the_search_query</h3>
<p>Writing the &quot;search&quot; buffer means writing the search query, and <code>write_buffer</code> is the one place that knows it (<a class="tid" href="../tickets.html#improvise-gdl" title="SetBuffer effect special-cases the &quot;search&quot; buffer name">improvise-gdl</a>). Every buffer-writing command routes through it, so <code>clear-buffer search</code> clears the query rather than parking an unread entry in the map.</p>
<h3 id="colon-wq-saves-then-quits"><a class="anchor" href="#colon-wq-saves-then-quits">#</a>colon_wq_saves_then_quits</h3>
<p><a class="tid" href="../tickets.html#improvise-git" title="Replace SaveAndQuit with Binding::Sequence in keymap">improvise-git</a> proposed deleting the <code>SaveAndQuit</code> command and binding <code>ZZ</code> to a <code>Binding::Sequence [w, force-quit]</code> instead — the repo&#39;s &quot;do not name a command AAndB&quot; principle.</p>
<p>That premise is incomplete, and this test is why. <code>wq</code> is not only a keybinding: it is a <strong>registered command name</strong>, so <code>:wq</code> reaches it through the registry, and <code>Quit</code>&#39;s own unsaved-changes message advertises it (&quot;Use :q! to force quit or :wq to save+quit&quot;). A keymap <code>Sequence</code> is not reachable from the <code>:</code> line, so deleting the command deletes the ex-command with it.</p>
<p>Keeping the command <em>and</em> adding the Sequence would leave two independently-written spellings of &quot;save then quit&quot;, which is the duplication this codebase keeps curing rather than creating. So this pins the surface, and the conversion is declined on it.</p>
<h3 id="colon-enter-edit-at-cursor-is-not-snapped-back-to-normal"><a class="anchor" href="#colon-enter-edit-at-cursor-is-not-snapped-back-to-normal">#</a>colon_enter_edit_at_cursor_is_not_snapped_back_to_normal</h3>
<p><a class="tid" href="../tickets.html#improvise-6sq" title="Merge EditOrDrill and EnterEditAtCursorCmd into EnterEdit{mode}">improvise-6sq</a>: <code>ExecuteCommand</code> appends <code>change_mode(Normal)</code> unless some effect reports <code>changes_mode()</code>. <code>EnterEditAtCursor</code> sets <code>view_state.mode</code> but did not override it, so <code>:enter-edit-at-cursor editing</code> produced <code>[RebuildLayout, EnterEditAtCursor(Editing), ChangeMode(Normal)]</code> — the trailing snap-back undid the edit mode and the user never got a cursor in the minibuffer.</p>
<p>Asserting on <code>changes_mode()</code> alone is not enough here: the appended <code>ChangeMode(Normal)</code> reports <code>true</code> itself, so the assertion has to name the snap-back it is ruling out.</p>
<h3 id="colon-import-stays-in-the-wizard-on-success"><a class="anchor" href="#colon-import-stays-in-the-wizard-on-success">#</a>colon_import_stays_in_the_wizard_on_success</h3>
<p><a class="tid" href="../tickets.html#improvise-toc" title="Effect::changes_mode() is a static flag but ViewBack/ViewForward/StartImportWizard change the mode conditionally">improvise-toc</a>: <code>StartImportWizard</code> swaps the mode only when the file parses, so a per-type <code>changes_mode()</code> cannot describe it. Reporting <code>false</code> made <code>ExecuteCommand</code> append a return-to-Normal on top of the success path: <code>:import d.json</code> opened the wizard and was immediately snapped back, leaving the wizard drawn under the Normal keymap. (Gotcha 31.)</p>
<h3 id="colon-import-falls-back-to-normal-on-failure"><a class="anchor" href="#colon-import-falls-back-to-normal-on-failure">#</a>colon_import_falls_back_to_normal_on_failure</h3>
<p>The other half of the same bug: reporting <code>true</code> unconditionally would strand the user in <code>CommandMode</code> when the import fails. A failed <code>:import</code> must still fall back to Normal.</p>
<h3 id="colon-view-back-respects-whether-history-exists"><a class="anchor" href="#colon-view-back-respects-whether-history-exists">#</a>colon_view_back_respects_whether_history_exists</h3>
<p>The <code>ViewBack</code> half of <a class="tid" href="../tickets.html#improvise-toc" title="Effect::changes_mode() is a static flag but ViewBack/ViewForward/StartImportWizard change the mode conditionally">improvise-toc</a>: it restores the stored frame&#39;s mode only when the back-stack is non-empty. With history present, <code>:view-back</code> must keep the mode it restored; with an empty stack it falls through to Normal like any other no-op command.</p>
<hr />
<h2 id="command-cmd-wizard-rs"><a class="anchor" href="#command-cmd-wizard-rs">#</a><code>command/cmd/wizard.rs</code></h2>
<h3 id="name-model-enter-builds-the-model"><a class="anchor" href="#name-model-enter-builds-the-model">#</a>name_model_enter_builds_the_model</h3>
<p>Enter on the name step builds the workbook into the wizard rather than advancing the step machine; every other printable key edits the name (gotcha 22).</p>
<h3 id="done-confirms-on-enter-and-steps-back-on-esc"><a class="anchor" href="#done-confirms-on-enter-and-steps-back-on-esc">#</a>done_confirms_on_enter_and_steps_back_on_esc</h3>
<p>On the confirmation step Enter installs the built workbook and Esc steps back — it does not throw the import away, and no key closes the wizard outright (<a class="tid" href="../tickets.html#improvise-907" title="Import wizard step machine: validate early, allow going back, make the terminal step real">improvise-907</a>, from <a class="tid" href="../tickets.html#improvise-c6j" title="WizardStep::Done is unreachable from the UI">improvise-c6j</a>).</p>
<h3 id="a-formula-owned-cell-is-projected-as-derived-and-its-siblings-are-not"><a class="anchor" href="#a-formula-owned-cell-is-projected-as-derived-and-its-siblings-are-not">#</a>a_formula_owned_cell_is_projected_as_derived_and_its_siblings_are_not</h3>
<p><a class="tid" href="../tickets.html#improvise-ir7l" title="[bug] A formula cell is offered as editable and silently reverts">improvise-ir7l</a>. <code>project::cell_id</code> decided <code>Record</code> vs <code>Aggregate</code> purely on whether a regular category sat on <code>Axis::None</code>; it never asked whether the <code>_Measure</code> coordinate named a formula target. So a formula cell was a <code>Record</code>, <code>render.rs::editable</code> said true, and a write to it was accepted, stored and then shadowed forever by <code>formula_cache</code>.</p>
<p>The fault injected: replacing the <code>derived</code> lookup with <code>None</code>, which is the pre-fix code exactly. The test then reports <code>Record(RecordCell { measure: Some(&quot;Profit&quot;) })</code> where it wants <code>Derived { target: &quot;Profit&quot; }</code>. The siblings are asserted in the same test so a producer that answered <code>Derived</code> for <em>every</em> cell could not pass.</p>
<h3 id="an-aggregating-formula-cell-is-still-projected-as-an-aggregate"><a class="anchor" href="#an-aggregating-formula-cell-is-still-projected-as-an-aggregate">#</a>an_aggregating_formula_cell_is_still_projected_as_an_aggregate</h3>
<p>The negative arm of the same change, and the one a careless fix breaks: gotcha 10 says drilling into a formula cell is supported, so <code>Aggregate</code> must still win where the cell aggregates. Proved discriminating by returning <code>derived</code> before the aggregate branch — the cell then reads <code>Derived</code> and becomes undrillable, with nothing else in the suite noticing.</p>
<h3 id="edit-or-drill-refuses-a-formula-owned-cell"><a class="anchor" href="#edit-or-drill-refuses-a-formula-owned-cell">#</a>edit_or_drill_refuses_a_formula_owned_cell</h3>
<p>The TUI half. <code>EditOrDrill</code> never asked the question either, so the terminal offered the same editor over the same shadowed cell. Landing only the projection half would have made the two front ends disagree, which is the defect design-principles §5 is about. Fault injected by deleting the guard: the command returns <code>[SetBuffer{edit}, ChangeMode(Editing)]</code> again.</p>
<h3 id="a-save-clears-the-dirty-marker-on-a-client-although-nothing-is-dirty"><a class="anchor" href="#a-save-clears-the-dirty-marker-on-a-client-although-nothing-is-dirty">#</a>a_save_clears_the_dirty_marker_on_a_client_although_nothing_is_dirty</h3>
<p><a class="tid" href="../tickets.html#improvise-z754" title="Document name, dirty flag and file path have no home a client can read">improvise-z754</a>, and the arm the whole design turns on. <code>effect::Save</code> is <code>EffectKind::ViewOnly</code>, so <code>Dirty::of</code> reports <code>NOTHING</code> — the test asserts that first, so it cannot quietly stop being the interesting case if <code>Save</code> is ever reclassified. A title bar gated on <code>Dirty</code> would therefore leave every client showing <code>[+]</code> for a document that has just been saved, forever, with nothing in the stream to correct it.</p>
<p>Fault injected: wrapping <code>document_update</code> in <code>if !dirty.is_empty()</code>, which is the obvious implementation and the wrong one. The test fails on the cleared arm. <code>a_title_bar_that_did_not_move_is_not_projected</code> is its counterweight — without it, &quot;project the title bar every batch&quot; would pass this one.</p>
<h3 id="the-title-bar-names-the-document-without-naming-the-path"><a class="anchor" href="#the-title-bar-names-the-document-without-naming-the-path">#</a>the_title_bar_names_the_document_without_naming_the_path</h3>
<p>The fixture&#39;s path is <code>/home/someone/private/books.improv</code> on purpose: a test whose fixture is a bare file name cannot tell <code>file_name()</code> from the whole path, and the failure mode is a socket server handing a remote client the host&#39;s directory layout.</p>
<h3 id="a-creating-column-is-named-rather-than-left-as-no-cell"><a class="anchor" href="#a-creating-column-is-named-rather-than-left-as-no-cell">#</a>a_creating_column_is_named_rather_than_left_as_no_cell</h3>
<p><a class="tid" href="../tickets.html#improvise-poia" title="The records-mode + column has no projected identity">improvise-poia</a>. Both <code>+</code> and <code>+cat</code> projected <code>None</code>, which the protocol defines as &quot;no projected cell&quot; — the same conflation gotcha 42 names for aggregates, arriving a third time.</p>
<p>The test walks <strong>every</strong> column of a records grid and counts, rather than asserting the two creating columns alone. That matters in the direction a weaker test fails: a producer that answered <code>Create</code> for everything, or that made the <code>Region</code> coordinate column nameable, would pass a two-position check and fails this one. The fault injected — returning <code>None</code> for both creating columns, which is the pre-fix code — reports three nameless columns where one is allowed.</p>
<h3 id="a-records-coordinate-is-text-however-it-is-spelled"><a class="anchor" href="#a-records-coordinate-is-text-however-it-is-spelled">#</a>a_records_coordinate_is_text_however_it_is_spelled</h3>
<p><a class="tid" href="../tickets.html#improvise-tom1" title="Drill pending edits are invisible to remote clients, and there are now three answers to &#39;what text is in this cell&#39;">improvise-tom1</a>. <code>project::cell_display</code>&#39;s records arm called <code>records_display</code> — which returns a <code>String</code> — and then inferred numeric-ness with <code>text.parse::&lt;f64&gt;()</code>. A records coordinate column holds an item <strong>name</strong>, so a <code>Year</code> category with items <code>2024</code>/<code>2025</code> projected a coordinate the browser right-aligns as a number.</p>
<p>Nothing in the suite could see it: every records fixture in the tree names its categories <code>Region</code>/<code>East</code>/<code>North</code>, so the guess happened to be right. The fixture here is the whole test — a category whose items are spelled like numbers — and the measure column beside it is asserted numeric in the same test, so a producer that answered <code>false</code> for everything would not pass.</p>
<p>Fault injected: the <code>parse::&lt;f64&gt;()</code> guess restored verbatim. It reports the coordinate as numeric.</p>
<h3 id="a-staged-drill-edit-reaches-its-own-session-and-no-projection"><a class="anchor" href="#a-staged-drill-edit-reaches-its-own-session-and-no-projection">#</a>a_staged_drill_edit_reaches_its_own_session_and_no_projection</h3>
<p>The user&#39;s ruling, pinned in both directions (<a class="tid" href="../tickets.html#improvise-tom1" title="Drill pending edits are invisible to remote clients, and there are now three answers to &#39;what text is in this cell&#39;">improvise-tom1</a>). A remote client must NOT see another client&#39;s staged drill edits, so <code>grid_of</code> must project the committed value; the session that staged one must still see its own typing, through <code>App::display_value_of</code>.</p>
<p>Asserting only the second half would pass against a producer that leaked, and asserting only the first would pass against one where staging did nothing at all — so both are here. The fault injected is the leak: teaching <code>cell_display</code>&#39;s records arm to prefer <code>drill_state.pending_edits</code>, which is the plausible &quot;fix&quot; this ticket exists to forbid. The projected cell then reads <code>999</code> instead of <code>1</code>.</p>
<p>The fixture takes its <code>DrillState::records</code> from the layout the app really built, because an empty frozen snapshot silently empties the drill grid and the test then asserts nothing about a cell that does not exist — the first version did exactly that and panicked on <code>expect</code>.</p>
<h3 id="a-page-axis-carries-its-items-and-a-filter-axis-does-not"><a class="anchor" href="#a-page-axis-carries-its-items-and-a-filter-axis-does-not">#</a>a_page_axis_carries_its_items_and_a_filter_axis_does_not</h3>
<p>The producer half of <a class="tid" href="../tickets.html#improvise-p1ym" title="Project the page-axis item lists so [ and ] work in a browser">improvise-p1ym</a>, added during self-review because <code>a_filter_axis_is_projected_but_not_cycled</code> builds its own <code>PageAxis</code> by hand — a test that fabricates the very data the code under test is supposed to produce, which is this repo&#39;s characteristic defect. Only this one can say what <code>grid_of</code> actually emits.</p>
<p>The fixture puts <code>Quarter</code> on <code>Axis::Page</code> and <code>Year</code> on <code>Axis::Filter</code>, which is the shape a drill makes (gotcha 27), and asserts the whole projected list in one comparison so a producer that emitted items for <strong>everything</strong> or for <strong>nothing</strong> both fail. Fault injected: <code>let navigable = true</code>, which is <code>filtering_cats()</code> leaking into the protocol — the exact trap the ticket names. <code>Year</code> comes back carrying <code>[&quot;2024&quot;]</code>.</p>
<h3 id="a-records-grid-has-no-column-for-a-formula-target"><a class="anchor" href="#a-records-grid-has-no-column-for-a-formula-target">#</a>a_records_grid_has_no_column_for_a_formula_target</h3>
<p>A records grid has no column for a formula target that is <strong>only</strong> a formula target, because <code>GridLayout::records_columns</code> builds its measure columns from <code>Category::ordered_item_names</code> — the explicit <code>_Measure</code> items — while <code>add_formula</code> adds none (gotcha 7). Fault injected: swapping <code>ordered_item_names</code> for <code>measure_item_names</code> in <code>records_columns</code>, which is a plausible tidy-up — a <code>Profit</code> column appears.</p>
<p><strong>This test&#39;s original premise was FALSE, and the review caught it.</strong> It was written claiming the records branch of <code>cell_id</code> was <em>safe</em> because &quot;a formula target lives only in <code>measure_item_names</code>&quot;. An item can be <strong>both</strong> an explicit <code>_Measure</code> item and a formula target, and <a class="tid" href="../tickets.html#improvise-poia" title="The records-mode + column has no projected identity">improvise-poia</a> — in the same PR — made the <code>+</code> column that builds exactly that reachable. So this test asserted the accident in the one configuration where the accident holds, and its own failure message named a repair for a case it could not reach. The real guards are <code>a_records_column_a_formula_owns_is_derived_not_a_record</code> and <code>typing_into_a_records_formula_column_is_refused_on_the_key_path</code>; this one now claims only what it can see.</p>
<h3 id="a-records-column-a-formula-owns-is-derived-not-a-record"><a class="anchor" href="#a-records-column-a-formula-owns-is-derived-not-a-record">#</a>a_records_column_a_formula_owns_is_derived_not_a_record</h3>
<p>The hole <a class="tid" href="../tickets.html#improvise-ir7l" title="[bug] A formula cell is offered as editable and silently reverts">improvise-ir7l</a>&#39;s first fix left, found by adversarial review. A measure can be <strong>both</strong> an explicit <code>_Measure</code> item and a formula target — the records <code>+</code> column creates precisely that — and <code>cell_id</code>&#39;s records branch asked <code>formula_for</code> about nothing, so it projected <code>CellId::Record{measure:&quot;Profit&quot;}</code>. A write there is accepted, stored, and then shadowed forever by <code>formula_cache</code>, which is the defect the ticket exists to close, surviving in records mode in <strong>both</strong> front ends since <code>editable</code> is <code>Record</code>-only.</p>
<p>The repair asks the document about the record&#39;s <strong>real</strong> coordinate — <code>GridLayout::record_cell_key</code>, the one mapping from a records position to a model cell — never the synthetic <code>(_Index, _Dim)</code> key, which names no measure and so can never match a formula. Fault injected: deleting the lookup, which is the pre-review code. <code>Profit</code> reads back as a <code>Record</code>. The <code>Revenue</code> column is asserted still writable in the same test, so a producer answering <code>Derived</code> for everything could not pass.</p>
<h3 id="typing-into-a-records-formula-column-is-refused-on-the-key-path"><a class="anchor" href="#typing-into-a-records-formula-column-is-refused-on-the-key-path">#</a>typing_into_a_records_formula_column_is_refused_on_the_key_path</h3>
<p>The same defect through <code>App::handle_key</code> and no test API at all: <code>R</code>, walk to <code>+</code>, type <code>Profit</code>, Enter, Esc, then <code>i</code> on the column just created. It exists because the projection half and the <code>EditOrDrill</code> half are <strong>separately</strong> breakable, and a unit test of either would have missed the other — proved by injecting each alone. Restoring <code>cell_id</code> while leaving <code>CmdContext:: owning_formula</code> reading the raw key fails on the mode assertion; the reverse fails on the identity assertion.</p>
<p>One fixture note that cost a debugging round: <code>CommitAndAdvance</code> <strong>re-enters editing</strong> on the cell it advances to, so the mode after Enter is <code>RecordsEditing</code> and an <code>i</code> there is a literal character. Without the Esc the test passes its mode assertion for the wrong reason — nothing happened at all.</p>
<h3 id="a-save-tells-the-client-the-document-is-clean"><a class="anchor" href="#a-save-tells-the-client-the-document-is-clean">#</a>a_save_tells_the_client_the_document_is_clean</h3>
<p><a class="tid" href="../tickets.html#improvise-z754" title="Document name, dirty flag and file path have no home a client can read">improvise-z754</a>&#39;s own stated failure mode, shipped: &quot;every client showing a <code>[+]</code> for a document that has been saved&quot;. <code>Host::save</code> applied <code>effect::Save</code> and returned only a possible <code>Rejected</code>, never projecting, so the dirty marker cleared <strong>outside</strong> any projection and no client was ever told.</p>
<p>The z754 test that was supposed to cover this called <code>sessions.project_all</code> by hand — which is exactly the call the production path omits. It is not a test that compares a thing with itself; it is a test that exercises a call the deployment never makes, which is the subtler shape. This one goes through <code>Host::handle(ClientFrame::Save)</code>.</p>
<p>Two assertions guard the fixture: the edit must first report <code>dirty: true</code>, and <code>app.model_state.dirty</code> must really be <code>false</code> afterwards — otherwise a host that saved nothing would pass by projecting nothing. Fault injected: returning an empty vec from the success arm. No <code>Document</code> update is emitted.</p>
<h3 id="the-automatic-persist-tells-the-client-the-document-is-clean"><a class="anchor" href="#the-automatic-persist-tells-the-client-the-document-is-clean">#</a>the_automatic_persist_tells_the_client_the_document_is_clean</h3>
<p>The worker&#39;s half of the same defect, and a different cause: <code>on_frame</code> built <code>out</code> via <code>frames_for</code> and <em>then</em> called <code>persist()</code>, so the projections were built while <code>dirty</code> was still true. The browser is the deployment where this bites hardest, because nothing there ever sends <code>ClientFrame::Save</code> — the worker persists on its own behalf (gotcha 45), so this was the only path.</p>
<p>Fault injected: dropping the re-projection after <code>persist()</code>. The client&#39;s <code>DocumentCache</code> stays dirty. <code>MainThread</code> grew a <code>document</code> mirror and a projection dispatch for it, rather than a bespoke reach into the worker — the point is what a client folding the real stream ends up holding.</p>
<p><strong>A first version asserted only <code>!document.dirty</code>, and <code>DocumentCache::default()</code> is already clean</strong> — so it could not tell &quot;told it cleared&quot; from &quot;never told anything&quot;, and passed under an injection that stopped projecting the widget entirely. Found in re-review. It now records <strong>every</strong> marker the client is told and asserts <code>[true, false]</code> for the edit, with the bootstrap&#39;s own clean marker cleared first so the sequence is about the edit rather than the handshake. The stronger form fails under both injections.</p>
<h3 id="a-refused-or-duplicate-add-category-moves-no-counter"><a class="anchor" href="#a-refused-or-duplicate-add-category-moves-no-counter">#</a>a_refused_or_duplicate_add_category_moves_no_counter</h3>
<p><a class="tid" href="../tickets.html#improvise-75ld" title="A refused or no-op AddCategory still bumps layout_version and raises dirty">improvise-75ld</a>. <code>App::apply_effects</code> bumped <code>layout_version</code> and raised <code>model_state.dirty</code> off the <code>EffectKind</code> <strong>before</strong> the <code>abort_effects</code> check, so the refusing effect counted itself. Two arms, and the second is the reachable one: a 13th category is refused (<code>MAX_CATEGORIES</code>) and aborts, while a <em>duplicate</em> <code>add-cat Region</code> returns <code>Ok</code> and does not abort at all — a pure no-op that still refused every other client&#39;s next <code>Command::Model</code> with <code>Rejection::LayoutMoved</code> and made the browser worker rewrite the whole OPFS document (gotcha 41).</p>
<p>Injected fault: put the two bumps back on the declared kind, inside the loop (<code>if matches!(kind, EffectKind::Model(ModelChange::LayoutGeometry))</code>). Both no-op arms fail; so do <code>neither_shape_counter_moves_for_an_accepted_no_op</code>, <code>both_shape_counters_move_for_every_reshaping_command</code> and <code>a_configuration_change_that_draws_nothing_is_still_a_document_change</code>, with 450 other tests still passing — the build ran.</p>
<p>The positive arm is not decoration: without it, &quot;never bump anything&quot; passes the two no-op arms. It also fails under a second injection — dropping the <code>document_config</code> comparison — because <code>AddCategory</code> is <code>LayoutGeometry</code> and the declared half of <code>dirty</code> no longer covers it.</p>
<h3 id="both-shape-counters-move-for-every-reshaping-command"><a class="anchor" href="#both-shape-counters-move-for-every-reshaping-command">#</a>both_shape_counters_move_for_every_reshaping_command</h3>
<p><a class="tid" href="../tickets.html#improvise-pdvm" title="AddItem/RemoveItem reshape the grid but declare CellData, so layout_version and cursor_epoch disagree">improvise-pdvm</a>. <code>AddItem</code>, <code>AddItemInGroup</code> and <code>RemoveItem</code> declare <code>ModelChange::CellData</code> and reshape the grid. Since <a class="tid" href="../tickets.html#improvise-9mo9" title="[bug] Geometry effects that renumber the grid but never reset the cursor">improvise-9mo9</a> they call <code>App::reset_cursor</code>, so <code>cursor_epoch</code> moved while <code>layout_version</code> did not: a client was told its cursor was invalid while its layout basis tier still read current, and a batch composed against the old shape was still accepted.</p>
<p>It drives <code>reshaping_commands()</code> — the same table <code>every_reshaping_model_command_invalidates_every_cursor</code> drives, extracted into a shared function precisely so the two cannot answer for different cases — and asserts the grid shape really moved before asserting either counter, so a case that reshapes nothing fails loudly rather than proving nothing.</p>
<p>Injected fault: as above. Fails naming <code>SetAxis</code>, then <code>AddItem</code> once the earlier cases are removed.</p>
<h3 id="a-configuration-change-that-draws-nothing-is-still-a-document-change"><a class="anchor" href="#a-configuration-change-that-draws-nothing-is-still-a-document-change">#</a>a_configuration_change_that_draws_nothing_is_still_a_document_change</h3>
<p>The third case the shape/config split creates, and the one that forced two fingerprints rather than one. <code>hide_item</code> records a name whether or not the model has one, and <code>SetNumberFormat</code> writes the file while moving no row or column: both are <code>dirty</code> and neither is <code>layout_version</code>. A single fingerprint covering the persisted configuration made a ghost hide bump the basis; one covering only the drawn shape let a format change go unsaved.</p>
<p>Injected fault: remove the <code>document_config</code> comparison in <code>observe_reshape</code>. Fails naming <code>HideItem { category: &quot;Region&quot;, item: &quot;NoSuchItem&quot; }</code>.</p>
<h3 id="a-reshape-of-one-view-leaves-a-client-on-another-view-alone"><a class="anchor" href="#a-reshape-of-one-view-leaves-a-client-on-another-view-alone">#</a>a_reshape_of_one_view_leaves_a_client_on_another_view_alone</h3>
<p><a class="tid" href="../tickets.html#improvise-nynk" title="adopt_cursor_epoch resets every client&#39;s cursor, including clients on a view nothing reshaped">improvise-nynk</a>. <a class="tid" href="../tickets.html#improvise-jytm" title="Split active_view: document default vs per-client authoritative (improvise-g0f)">improvise-jytm</a> made &quot;which view a client is on&quot; per-client; <code>Sessions::adopt_cursor_epoch</code> was not updated and still reset <strong>every</strong> mirror and returned one <code>Broadcast::ToAll(Command::ResetCursor)</code>. A reshape of view <code>Default</code> therefore threw away the cursor of a client sitting on <code>Second</code>, which nothing reshaped.</p>
<p>The fixture is two views of one document with different axes, and the bystander switches to <code>Second</code> and scrolls there before the asker reshapes <code>Default</code>. The positive arm is kept in the same test — the asker is on the reshaped view, is reset, and is told — because without it &quot;never invalidate anyone&quot; passes.</p>
<p>Injected fault: drop the <code>cursor_invalidated</code> filter (<code>.filter(|_| true)</code>). Fails naming the bystander&#39;s thrown-away cursor, with 453 other tests passing.</p>
<h3 id="an-unknown-reshape-set-invalidates-everybody"><a class="anchor" href="#an-unknown-reshape-set-invalidates-everybody">#</a>an_unknown_reshape_set_invalidates_everybody</h3>
<p>The fail-safe arm of the same narrowing, tested separately because it is the half gotcha 24 calls strictly worse than doing nothing. An empty reshaped set means the batch moved a cursor with no view observed to change shape; the answer must be &quot;everybody&quot;, not &quot;nobody&quot;, or the diff believes each client already holds origin-positioned content and sends nothing, leaving them stale permanently.</p>
<p>Injected fault: delete the <code>reshaped.is_empty() ||</code> disjunct. Fails by name. That injection is why the predicate is a named function rather than a closure — a fail-safe nothing can point at is a fail-safe that gets tidied away.</p>
<h3 id="a-batch-that-changes-the-layout-and-then-saves-ends-clean"><a class="anchor" href="#a-batch-that-changes-the-layout-and-then-saves-ends-clean">#</a>a_batch_that_changes_the_layout_and_then_saves_ends_clean</h3>
<p>A regression introduced by the first version of the observed-<code>dirty</code> change and caught in review of PR #70, not by the suite — the workspace was green at 1400 with this broken. <code>observe_reshape</code> ran <strong>after</strong> the effect loop and re-raised <code>model_state.dirty</code> from the <code>document_config</code> comparison, while <code>Save</code> had cleared it <strong>inside</strong> the loop. So gotcha 45&#39;s stated ordering property — &quot;<code>Save</code> clears it in loop order, so a batch that edits and then saves ends clean&quot; — survived for <code>CellData</code>/<code>WholeWorkbook</code> and was broken for <code>LayoutGeometry</code>.</p>
<p><code>[SetNumberFormat, Save]</code> reported a successful save and left the document dirty: the TUI draws &quot;Saved to …&quot; while <code>Quit</code>&#39;s check refuses a later <code>:q</code>, and the browser worker — whose persist trigger <em>is</em> the flag — pays a whole-document OPFS rewrite. Reachable from <code>:format ,.2f . w</code> and from any <code>Binding::Sequence</code> pairing a geometry effect with a save.</p>
<p>The fix is position, not logic: the comparison moved inside the loop, taken after each <code>LayoutGeometry</code> effect against the value held after the previous one. Both arms are in the test — <code>[SetCell, Save]</code> never left the loop, so the geometry arm alone could not say whether the rule is general — and each asserts the save really happened (<code>last_batch_aborted</code>, and a status line starting &quot;Saved&quot;) before asserting the flag, or a failed save would pass the dirty assertion for the wrong reason.</p>
<p>Injected fault: move the comparison back after the loop. Fails naming the layout arm, with 454 others passing.</p>
<p>The lesson worth carrying: <strong>an observation that is correct about <em>what</em> changed can still be wrong about <em>when</em> it is read.</strong> Everything above this entry argues that observing beats declaring; this is the cost of that move, and it lands precisely on the one effect that <em>unsets</em> what the observation sets.</p>
<h3 id="the-published-basis-names-the-records-the-grid-draws-and-no-others"><a class="anchor" href="#the-published-basis-names-the-records-the-grid-draws-and-no-others">#</a>the_published_basis_names_the_records_the_grid_draws_and_no_others</h3>
<p><a class="tid" href="../tickets.html#improvise-23qd" title="The Projection::Basis record set is derived twice, and the second copy is the tests&#39; yardstick">improvise-23qd</a>. The set of records a <code>Projection::Basis</code> carries was derived <strong>twice</strong> — <code>ClientSession::basis_update</code>, the producer, and <code>server_truth</code>, a <code>#[cfg(test)]</code> helper that is the <strong>yardstick every fold test measures against</strong>. So a change to which records get published moved the thing and its own measure together, and every test stayed green whatever the change was. Not a helper that fabricates an input; a helper that fabricates the expected <em>output</em>.</p>
<p>Both now call <code>published_basis</code>. Note what that alone does <strong>not</strong> buy: the ticket&#39;s stated proof (&quot;alter the filter and watch the basis tests fail&quot;) is impossible once one function feeds both sides — they still move together. A shared derivation removes the drift and removes the yardstick with it.</p>
<p>So the yardstick is spelled out rather than derived: this test names North, South and East by hand for <code>app_with_a_reshapeable_grid</code>&#39;s three records over six cells, and asserts that a grid where every cell is an <code>Aggregate</code> publishes <strong>none</strong> — because an aggregate names no record, so there is nothing to claim a version for.</p>
<p>Fault injected: <code>.take(2)</code> on the one surviving filter — the ticket&#39;s exact &quot;change which records get published&quot; — fails this test <strong>and nothing else in the crate</strong>. A second injection, <code>record_version_of(..) + 1</code>, is caught instead by the two end-to-end <code>basis_check</code> tests, which is the split worth knowing: the counters have other guards, the membership had none.</p>
<p>The fixture carries its own premise check. The first version assumed a new category lands on <code>Axis::None</code>; it lands on <code>Column</code>, and the assertion said so instead of the test quietly proving nothing.</p>
<h3 id="an-aborted-batch-bumps-layout-version-for-what-it-did-run"><a class="anchor" href="#an-aborted-batch-bumps-layout-version-for-what-it-did-run">#</a>an_aborted_batch_bumps_layout_version_for_what_it_did_run</h3>
<p><a class="tid" href="../tickets.html#improvise-2avl" title="layout_version moved outside the effect loop and neither abort arm is tested">improvise-2avl</a>, with its negative twin <code>an_aborted_batch_bumps_nothing_for_effects_it_never_ran</code>. PR #70 moved the <code>layout_version</code> bump from &quot;once per <code>LayoutGeometry</code> effect, inside the loop&quot; to &quot;once per batch, after the loop&quot;. Gotcha 40 records the in-loop position as load-bearing — an aborted batch must not claim effects it never ran — and the author&#39;s argument for why end-of-batch is safe <em>for <code>layout_version</code> specifically</em> (nothing ever clears it, so an end comparison sees only what ran, whereas <code>dirty</code> must stay in the loop because <code>Save</code> unsets it mid-batch) is sound and was written into <a href="../code/command.html"><code>context/code/command.md</code></a>. <strong>An argument is not a test</strong>, and neither arm had one.</p>
<p>Both arms are proved <strong>independently</strong>, which is the part that earns them: bumping unconditionally fails only the second test, and skipping <code>observe_reshape</code> on abort fails only the first. A single injection failing both would not have shown that the two tests see different things.</p>
<p>Each fixture asserts <code>app.last_batch_aborted()</code> before asserting anything about the counter, because a batch that did not actually abort would pass both arms while testing neither.</p>
<h3 id="sorting-the-store-in-records-mode-invalidates-the-cursor"><a class="anchor" href="#sorting-the-store-in-records-mode-invalidates-the-cursor">#</a>sorting_the_store_in_records_mode_invalidates_the_cursor</h3>
<p><a class="tid" href="../tickets.html#improvise-ni6k" title="Invalidate the cursor by observing the grid frame, not by a per-effect table">improvise-ni6k</a> names <code>SortData</code> as its <strong>sharpest</strong> case: it changes neither the row count nor any label, and every position still names a different record. A guard written as &quot;did the shape change&quot; misses it; <code>pending_edits</code> are keyed by row index (gotcha 1), so row 0 being West and then East is gotcha 18&#39;s misdirected-write disease arriving through a different door.</p>
<p><code>view_frame</code> therefore hashes each record&#39;s <strong>coordinates</strong> alongside the axis entries, because records-mode <code>row_items</code> are positional — <code>0,1,2…</code> whatever order the store is in. Fault injected: deleting those three lines fails this test and <strong>nothing else in the workspace</strong>.</p>
<p><strong>The fixture trap, which is the durable part.</strong> PR #78 first shipped without the coords hash, on the stated ground that no fixture could be built where <code>SortData</code> reorders anything. That was wrong, and the reason is worth more than the fix: <code>ToggleRecordsMode</code> pushes <code>effect::SortData</code> as its <strong>first</strong> effect, so a records-mode fixture built by pressing <code>R</code> has an already-sorted store <em>by construction</em> and cannot demonstrate anything order-sensitive. The store itself was fine all along. So this fixture sets <code>_Index</code>/<code>_Dim</code> directly rather than going through the key path.</p>
<p>The test asserts the count and the labels are <strong>unchanged</strong> before asserting the cursor moved, so it cannot pass by accidentally becoming an ordinary shape change.</p>
<h3 id="paging-with-nothing-pruned-invalidates-no-cursor"><a class="anchor" href="#paging-with-nothing-pruned-invalidates-no-cursor">#</a>paging_with_nothing_pruned_invalidates_no_cursor</h3>
<p>The negative twin of <code>paging_across_a_prune_that_drops_a_row_invalidates_the_cursor</code>, and the reason <code>view_frame</code> hashes <strong>no page selection</strong>.</p>
<p><a class="tid" href="../tickets.html#improvise-7kvw" title="SetPageSelection renumbers the grid under prune_empty and does not reset the cursor">improvise-7kvw</a> is real only under <code>prune_empty</code>: a page flip changes which records are visible, not which categories frame them, so with pruning off a grid position keeps naming the same cell. PR #76 ruled that homing the cursor on every <code>[</code>/<code>]</code> regardless — <code>[</code>/<code>]</code> being navigation a user does constantly — is <strong>worse than the hole</strong>. Hashing the page selection does exactly that.</p>
<p>The seductive argument for hashing it anyway is that two pages with identical row labels really are different data. True, and irrelevant: that is <code>record_versions</code>&#39; question, and <code>view_frame</code> answers the cursor&#39;s. A basis argument applied to a cursor decision is how this regression got written in the first place.</p>
<p>Fault injected: restoring the three <code>page_selections</code> lines fails this test and nothing else. The positive arm keeps passing either way, because <code>with_frozen_records</code> applies <code>prune_empty</code> and so already sees a dropped row in the axis entries — which is why the negative arm is the one that had to exist.</p>
<h3 id="a-write-that-prunes-a-row-out-of-another-clients-view-invalidates-it"><a class="anchor" href="#a-write-that-prunes-a-row-out-of-another-clients-view-invalidates-it">#</a>a_write_that_prunes_a_row_out_of_another_clients_view_invalidates_it</h3>
<p><a class="tid" href="../tickets.html#improvise-mcq4" title="The frame fingerprint is a 4x regression on the held-key path improvise-748f just optimized">improvise-mcq4</a>&#39;s sting, found by the adversarial review of PR #78. The mcq4 fix narrowed the frame fingerprint from every view to the active one, on the strength of a measurement showing the narrowing cost nothing. <strong>It bought its speed partly with correctness</strong>: a <code>CellData</code> write that prunes a row out of <em>another</em> client&#39;s view became invisible. <code>view_shapes</code> is config-only — it hashes the <code>prune_empty</code> flag, not which rows survive — so <code>last_reshaped_views</code> is empty, the one-view frame covers only the host&#39;s, and a client on the other view is never sent <code>ResetCursor</code> while its grid loses a row underneath it. That is gotcha 24&#39;s &quot;strictly worse than doing nothing&quot;: the diff then believes the bystander is current, so it stays stale <strong>permanently</strong>.</p>
<p>The repair keeps the narrow one-view frame on the <code>LayoutGeometry</code> arm — the held-key path <a class="tid" href="../tickets.html#improvise-748f" title="A drill exited with no edits, and view-back, mark the document dirty">improvise-748f</a> exists for, where other views cannot reframe because <code>view_mut()</code> writes only the active one — and restores the all-views frame on the <code>CellData</code> arm. Measured: <strong>0%</strong> on the held-key path, <strong>+5.7%</strong> on an 18k-cell two-view <code>SetCell</code>, scaling linearly in view count.</p>
<p><strong>Writing this test found a second bug, which was masking the first.</strong> The precondition failed at first: <code>grid_in</code> built a non-active view with <code>GridLayout::new</code>, which does <strong>not</strong> apply <code>prune_empty</code>, so a client on another view was projected three rows where its own config gives two. Their grid never pruned, so it never lost a row, so the staleness was unobservable — a bug hiding a bug. It is the third instance of the <code>new</code>-versus-<code>with_frozen_records</code> trap (<code>view_frame</code> was the second), and fixing it broke no other test.</p>
<p>Fault injected: removing the all-views arm fails this test, plus <code>sorting_the_store_in_records_mode_invalidates_the_cursor</code> (<code>SortData</code> is <code>CellData</code>) and both cursor-audience tests — four in all.</p>
<h3 id="a-switch-then-a-geometry-effect-compares-within-one-view"><a class="anchor" href="#a-switch-then-a-geometry-effect-compares-within-one-view">#</a>a_switch_then_a_geometry_effect_compares_within_one_view</h3>
<p>Found by the same review. <code>frame_basis</code> was taken once at batch open against <code>view_state.active_view</code>; <code>SwitchView</code> is <code>ViewOnly</code>, so it did not re-base, and the next model effect hashed the <strong>new</strong> view and compared it against the <strong>old</strong> view&#39;s hash. A near-certain mismatch, so a spurious <code>reset_cursor()</code> and a wrong name in <code>last_reframed_views</code>.</p>
<p>Today&#39;s instances are benign — <code>DrillIntoCell</code> and <code>EnterRecordsMode</code> both emit <code>[CreateView, SwitchView, SetAxis, …]</code> in one batch, but against a view they just created. The harmful case needs a switch to an <em>existing</em> view with a saved cursor plus a model effect in the same batch, which a <code>.</code>-chained <code>:</code> line reaches. Low severity, unsound mechanism.</p>
<p>The basis is now keyed by view name and re-bases rather than comparing when the name moves. Fault injected: dropping the <code>was == self.view_name()</code> guard fails this test, with <code>cursor_epoch</code> moving for a batch that reframed nothing.</p>
<p><a class="tid" href="../tickets.html#improvise-9c9o" title="A geometry effect right after a mid-batch SwitchView skips the cursor check entirely">improvise-9c9o</a> moved <em>where</em> the re-base happens, and this test was unchanged by it because it names the property, not the mechanism — the re-base now fires on the switch rather than on the next geometry effect, and a batch that reframes nothing still invalidates nothing.</p>
<p>Its fixture carried a line that did nothing at all: <code>views[&quot;Second&quot;].clone_into(&amp;mut views[&quot;Default&quot;].clone())</code> clones into a temporary that is immediately dropped. It read as the thing making the two views differ, so had <code>create_view</code> ever started copying the active view&#39;s config this test would have gone vacuous with that line standing there looking like it prevented exactly that. It is deleted and replaced by the assertion it was pretending to be: the two views&#39; <code>view_frame</code> hashes really differ.</p>
<h3 id="a-geometry-effect-after-a-mid-batch-switch-still-invalidates-the-cursor"><a class="anchor" href="#a-geometry-effect-after-a-mid-batch-switch-still-invalidates-the-cursor">#</a>a_geometry_effect_after_a_mid_batch_switch_still_invalidates_the_cursor</h3>
<p>The other side of the guard above, and introduced by it. PR #78 round 1 compared frame hashes across a view change, which threw a cursor away spuriously; round 2 added <code>was == self.view_name()</code>, which fixed that and also <strong>exempted the first geometry effect after a mid-batch <code>SwitchView</code> from the check altogether</strong> — there is no basis for the new view yet, so the code re-based and skipped. Passes on pre-#78 <code>main</code> and on round-1 head; fails at the merged head.</p>
<p>Direction is why this is worth more than the bug it replaced. Round 1 kept a cursor it should have thrown away; round 2 threw away nothing it should have kept — which is gotcha 35&#39;s expensive side, since a stale cursor has nothing in the stream to correct it where a spurious invalidation costs one projection.</p>
<p>The fix is the ticket&#39;s: <strong>the basis follows <code>active_view</code></strong>, re-based in the loop as soon as the name moves, rather than at the next geometry effect. It did <strong>not</strong> need <code>SwitchView</code> to stop being <code>ViewOnly</code> and it is not a protocol change — the basis is <code>App</code>&#39;s own bookkeeping, and nothing about it is on the wire. The <code>was == self.view_name()</code> guard is gone from the comparison because it is now unreachable: by the time the geometry arm runs, the name and the basis agree by construction.</p>
<p>What it changes elsewhere, and why that is fine: <code>DrillIntoCell</code> and <code>EnterRecordsMode</code> both emit <code>[CreateView, SwitchView, SetAxis, SetAxis, …]</code>, so they now reset the cursor on the <em>first</em> <code>SetAxis</code> where they used to skip it and reset on the second. Same outcome, one effect earlier. <code>DeleteView</code> is the one <code>LayoutGeometry</code> effect that moves <code>active_view</code> itself; the re-base fires for it too, so the geometry arm compares the new view against itself and resets nothing — which is what the old skip did.</p>
<p>The fixture measures the row count the transpose must move rather than spelling it: a first version asserted <code>row_count() == 1</code> and the real answer is 2, which is the shape of a precondition that would have gone quietly wrong later. It fails on the unfixed tree at the epoch assertion, <code>left: 0 right: 0</code>.</p>
<h3 id="a-frozen-drill-does-not-take-its-opening-frame-from-the-held-layout"><a class="anchor" href="#a-frozen-drill-does-not-take-its-opening-frame-from-the-held-layout">#</a>a_frozen_drill_does_not_take_its_opening_frame_from_the_held_layout</h3>
<p><a class="tid" href="../tickets.html#improvise-mcq4" title="The frame fingerprint is a 4x regression on the held-key path improvise-748f just optimized">improvise-mcq4</a>&#39;s fix reads the batch&#39;s opening frame off <code>App::layout</code> rather than building a second pruned layout — that is where the 4.1×→1.9× came from. It is guarded on <code>drill_state.is_none()</code>, because a drill freezes the record list and the held layout is then <strong>not</strong> what <code>view_frame</code> would build for that view.</p>
<p><strong>The guard was correct, load-bearing, and tested by nothing</strong>: reverting it to the unconditional form left all 1439 tests passing. Textbook <code>doctrine(a_careful_comment_is_weak_evidence_that_anything_checks_it)</code>, and it was the most carefully argued comment in the PR.</p>
<p>The fixture freezes one record where the live query finds three — asserted, so a freeze that did not narrow could not pass — then runs a <code>SetNumberFormat</code> batch, which reframes nothing. Without the guard the opening basis is the frozen layout and the in-loop hash is the live one, so they differ and the cursor is reset for a batch that changed no row. Fault injected: replacing <code>self.drill_state.is_none()</code> with <code>true</code> fails this test and nothing else in the workspace.</p>
<h3 id="a-clients-grid-does-not-depend-on-which-view-its-host-is-on"><a class="anchor" href="#a-clients-grid-does-not-depend-on-which-view-its-host-is-on">#</a>a_clients_grid_does_not_depend_on_which_view_its_host_is_on</h3>
<p><a class="tid" href="../tickets.html#improvise-t6db" title="grid_in ignored prune_empty for a non-active view, hiding a staleness bug">improvise-t6db</a>. The one-line <code>grid_in</code> fix landed in PR #78 with no test of its own; this is the general property the reviewer specced — <strong>host-view independence</strong>: <code>grid_in(app_on_A, &quot;B&quot;, vp) == grid_in(app_on_B, &quot;B&quot;, vp)</code> for every ordered pair of views. It needs no second derivation of what a view&#39;s config means, which is why it is a property and not a second copy of <code>GridLayout</code>.</p>
<p><strong>The trap is the fixture, not the assertion.</strong> The reviewer&#39;s first version made the two views differ by turning <code>prune_empty</code> <strong>off</strong> in the second — plenty of differing config, and vacuous: with nothing to prune, <code>GridLayout::new</code> and <code>GridLayout::with_frozen_records</code> agree and the property passed on the buggy tree. So <code>app_with_three_views_and_an_empty_row</code> gives Service no cell at all and leaves all three views pruning, and the test asserts every view really is two rows before comparing anything — a fixture that stopped pruning would fail there rather than passing quietly.</p>
<p>The property is stated with <code>drill_state.is_none()</code> asserted, because a frozen drill snapshot is the one thing <code>grid_in</code> cannot reproduce for a view the host is not on (<code>with_frozen_records(.., None)</code> on the elsewhere path).</p>
<p>Fault injected 2026-09-21: restoring <code>GridLayout::new</code> in <code>grid_in</code> — the pre-#78 line — fails this test at the <code>assert_eq!</code>, not at the precondition. It also fails <code>a_write_that_prunes_a_row_out_of_another_clients_view_invalidates_it</code> and <code>a_hosts_none_cats_leak_into_another_views_formula_columns</code>: <strong>three</strong> tests, not the one an earlier draft of this entry claimed. The #78 fix has more coverage than it was credited with.</p>
<p><strong>The <code>none_cats</code> asymmetry is REAL, and the first draft of this entry reported it as unobserved from a fixture that lacked the condition.</strong> That correction is worth more than the test. The claim was that the fixture put <code>Product</code> on None in <code>Default</code> and on Column in <code>Second</code>; it did not — <code>add_category</code> defaults a new category to Column and nothing moved it, so all three views had <code>none_cats</code> <code>{_Index, _Dim}</code>, the stated input to the hazard was absent, and <code>Default</code> and <code>Second</code> were indistinguishable to <code>grid_in</code> apart from a <code>number_format</code> that is itself a no-op. An unprobed path reported as a negative result, which is <code>doctrine(an_injection_that_does_not_fail_names_an_untested_path)</code> applied to a <strong>setup</strong>, and the expensive kind: it stops the next person looking.</p>
<p>Setting <code>Product</code> to <code>Axis::None</code> explicitly made the property fail on the first run, and not on the value of a cell — <code>Second</code> lost <strong>both <code>Total</code> columns entirely</strong> when the host stood on <code>Default</code>. Filed as <a class="tid" href="../tickets.html#improvise-ac2z" title="A host&#39;s none_cats leak into another view&#39;s grid: formula columns vanish">improvise-ac2z</a>, pinned by the characterization test below, and the property now skips pairs whose <code>none_cats</code> differ. That skip is a <strong>bug being worked around</strong>, not a carve-out; it asserts <code>compared &gt; 0</code> so a future fixture cannot skip everything and pass.</p>
<h3 id="a-window-narrows-the-cells-and-neither-the-totals-nor-the-widths"><a class="anchor" href="#a-window-narrows-the-cells-and-neither-the-totals-nor-the-widths">#</a>a_window_narrows_the_cells_and_neither_the_totals_nor_the_widths</h3>
<p><a class="tid" href="../tickets.html#improvise-jmjz" title="grid_of evaluates the whole grid three times per frame; compute_col_widths is ~45% of it">improvise-jmjz</a>. <code>project::grid_of</code> evaluated the whole grid roughly three times per frame — <code>compute_col_widths</code> walked every cell for its text and then every cell again for the column totals it must size, and <code>grid_of</code> then evaluated the window a third time. <code>ui/viewport.rs::grid_body</code> now evaluates each cell once and derives the widths, the totals and the window from that value.</p>
<p>That fusion creates a hazard the three separate passes did not have: one walk now serves three consumers with different scopes. <strong>Two</strong> faults follow, not one — a window that leaks into the totals makes a column total the sum of the visible rows, and a window that leaks into the widths sizes a column by what happens to be on screen. Neither is visible from the output: the numbers simply get smaller, consistently.</p>
<p><strong>The fixture took three attempts, and what each one was blind to is the entry.</strong></p>
<p><em>First</em>: rows of <code>1</code>, <code>20</code>, <code>300000</code> under a measure named <code>Revenue</code>, asserting <code>col_widths[0] &gt; &quot;300021&quot;.len()</code>. It could not see a windowed <strong>width</strong> at all — <code>Revenue</code> is 7 characters against a 6-character value, so the assertion passed on the <strong>header</strong> alone. Worse, its other half was structurally incapable: comparing <code>grid_body</code>&#39;s widths against <code>compute_col_widths</code> cannot test windowing, because <code>compute_col_widths</code> <strong>is</strong> <code>grid_body</code> with an empty window. Windowing degrades both sides identically, both collapsing to the <code>MIN_COL_WIDTH</code> clamp. <strong>The same function twice is not a check, and it reads exactly like one.</strong> Measured by the PR #92 review: <code>if drawn { widen(..) }</code> left <strong>1476 passing, 0 failing</strong>.</p>
<p><em>Second</em>, the reviewer&#39;s: <code>1</code>, <code>888888888</code>, <code>-888888888</code> with a one-character measure name, so the total <strong>cancels to 1.0</strong> and neither it nor the header can supply the width. This fixed the width half and broke the other: the windowed row is <code>1.0</code> and the cancelled total is <em>also</em> <code>1.0</code>, so the <strong>totals</strong> assertion went blind to the row-window fault, which the widths assertion then happened to catch. Two assertions, one no longer discriminating, and nothing saying which.</p>
<p><em>Third</em>, and current: the same values with the <strong>large ones first</strong>, so the window holds <code>888888888</code> while the total is <code>1.0</code>. Each injection now fails on the assertion that names it:</p>
<ul>
<li><code>for ri in rows.clone()</code> — the whole walk windowed → <strong>totals</strong> <code>[888888888.0]</code> against <code>[1.0]</code></li>
<li><code>if drawn { widen(..) }</code> — widths only → <strong>widths</strong> <code>[10]</code> against <code>[11]</code></li>
</ul>
<p><code>10</code> rather than <code>5</code> is a smaller gap than the reviewer&#39;s fixture gave and a better witness: the missing character is the <strong>minus sign</strong>, which only <code>-888888888</code> carries and the window does not contain. The test also asserts the window really is narrower than the grid, so a fixture that stopped windowing would fail at the precondition.</p>
<p><strong>What this test does not do is prove the speedup</strong>, and no test can — the outputs are identical by construction, which is why the whole suite passed unchanged across the change. The evidence is the benchmark, before and after, in <a href="../code/command.html"><code>context/code/command.md</code></a>: about 3x, the number of passes removed.</p>
<h3 id="a-formulas-columns-survive-a-host-that-aggregates-differently"><a class="anchor" href="#a-formulas-columns-survive-a-host-that-aggregates-differently">#</a>a_formulas_columns_survive_a_host_that_aggregates_differently</h3>
<p><a class="tid" href="../tickets.html#improvise-ac2z" title="A host&#39;s none_cats leak into another view&#39;s grid: formula columns vanish">improvise-ac2z</a>, <strong>fixed</strong>. This replaces the characterization test <code>a_hosts_none_cats_leak_into_another_views_formula_columns</code>, whose message said that failing meant the bug was fixed; it did, and it is gone.</p>
<p><code>App::rebuild_layout</code> calls <code>recompute_formulas(&amp;none_cats)</code> for the <strong>host&#39;s</strong> view, populating <code>Model::formula_cache</code>. <code>project::grid_in</code> on the elsewhere path built the other view&#39;s <code>GridLayout</code> against that same cache, which is keyed by the coordinates the recompute used: with <code>Product</code> on None in the host&#39;s view the <code>Total</code> cells are keyed <em>without</em> a <code>Product</code> coordinate, a view with <code>Product</code> on Column asks for <code>(Region, Product, _Measure=Total)</code>, finds nothing, and — <code>prune_empty</code> being on — drops both <code>Total</code> columns. Not a wrong number: absent columns.</p>
<p>host on <code>Second</code>, projecting <code>Second</code> 6 columns, <code>Total</code> present host on <code>Default</code>, projecting <code>Second</code> 4 columns, no <code>Total</code> at all</p>
<p>The fix is <code>grid_in::rederived_for</code>: where the target view aggregates over different categories than the host&#39;s, it clones the model and recomputes the formulas for <strong>that</strong> view&#39;s <code>none_cats</code>. Three conditions gate the clone — another view, different <code>none_cats</code>, and at least one formula — so the common case pays nothing and a formula-free document pays nothing ever.</p>
<p><strong>This is an interim, and the cost is the reason.</strong> A clone of the whole model plus a fixed-point recompute, per projection, on exactly the path that is broken today. The ticket&#39;s first candidate — keying <code>formula_cache</code> by <code>none_cats</code> so one <code>Model</code> can hold more than one view&#39;s derived values — is the durable answer and is filed separately; it is an <code>improvise-core</code> change and did not belong in this wave&#39;s ground. What is bought meanwhile is correctness on a path that was silently wrong, <code>doctrine(the_elsewhere_path_is_only_as_correct_as_the_inputs_it_can_rebuild)</code>.</p>
<p><strong>Fault injected</strong>: <code>rederived_for</code>&#39;s condition forced to <code>false</code>. Two tests fail — this one, and <code>a_clients_grid_does_not_depend_on_which_view_its_host_is_on</code>, whose <code>none_cats</code> carve-out was removed at the same time and which is the general property this one is an instance of. The test asserts its two <strong>preconditions</strong> first — that <code>Second</code> on its own really draws the formula&#39;s columns, and that the two views really do aggregate over different categories — so a fixture that lost either fails rather than passing on two empties.</p>
<h3 id="a-view-named-command-lands-on-the-view-it-names"><a class="anchor" href="#a-view-named-command-lands-on-the-view-it-names">#</a>a_view_named_command_lands_on_the_view_it_names</h3>
<p><a class="tid" href="../tickets.html#improvise-lltt" title="[bug] A view-scoped ModelCommand names no view, so it lands on the host&#39;s">improvise-lltt</a>, the oldest open P1 here, opened by <a class="tid" href="../tickets.html#improvise-jytm" title="Split active_view: document default vs per-client authoritative (improvise-g0f)">improvise-jytm</a> making <code>ViewState::active_view</code> authoritative per client.</p>
<p>Nine <code>ModelCommand</code> variants configure a view — <code>SetAxis</code>, <code>CycleAxis</code>, <code>TransposeAxes</code>, <code>SetPageSelection</code>, <code>HideItem</code>, <code>ShowItem</code>, <code>ToggleGroup</code>, <code>TogglePruneEmpty</code>, <code>SetNumberFormat</code> — and each named its view <em>implicitly</em>: <code>model_effects</code> built an effect that wrote <code>App::view_mut()</code>, which is the <strong>host&#39;s</strong> slice. A client that had switched to <code>Second</code> therefore moved <code>Default</code>: its own grid did not change, and every other client&#39;s did.</p>
<p>The fix is the protocol change the ticket asks for. Each of the nine carries a <code>view: String</code>, <code>model_effects</code> maps it to <code>ViewTarget::Named</code>, and the nine effects carry a <code>ViewTarget</code> rather than reading an ambient one. There is no operandless form left: <code>App::view_mut()</code> is <strong>deleted</strong>, replaced by <code>view_mut_of(&amp;ViewTarget)</code>, so a future geometry effect has to say which view it writes. The host&#39;s own key path passes <code>ViewTarget::Active</code>, which is right because the TUI <em>is</em> the host and has no session to ask.</p>
<p><strong>Fault injected</strong>: <code>model_effects</code>&#39; <code>SetAxis</code> arm rebuilt with <code>ViewTarget::Active</code> (the pre-fix behaviour, with <code>view</code> bound and discarded so it still compiles). The test fails on <code>the asker&#39;s own view never moved</code> — <code>Second</code>&#39;s <code>Channel</code> stayed on <code>Row</code> while <code>Default</code>&#39;s moved to <code>Page</code>.</p>
<p>The fixture must seat the asker somewhere the host is not, and the two views must disagree about the category being moved: <code>Channel</code> is <code>Axis::None</code> in <code>Default</code> and <code>Axis::Row</code> in <code>Second</code>, and the command asks for <code>Axis::Page</code>, so both assertions separate the fixed code from the broken code in both directions.</p>
<h3 id="a-reframe-of-a-view-the-host-is-not-on-invalidates-its-clients"><a class="anchor" href="#a-reframe-of-a-view-the-host-is-not-on-invalidates-its-clients">#</a>a_reframe_of_a_view_the_host_is_not_on_invalidates_its_clients</h3>
<p><a class="tid" href="../tickets.html#improvise-lltt" title="[bug] A view-scoped ModelCommand names no view, so it lands on the host&#39;s">improvise-lltt</a>&#39;s second half, and the part the ticket did not predict. Naming the view made a reframe of a view the host is not on reachable for the first time, and <code>Sessions::adopt_cursor_epoch</code> could not see one: it returned early unless <code>App::cursor_epoch</code> had moved, and the epoch is bumped only by <code>reset_cursor</code>, which <code>apply_effects</code> calls only for the <strong>host&#39;s own</strong> view. So the clients on the reframed view were reset by nobody and told nothing — gotcha 24&#39;s strictly-worse-than-nothing state, permanently stale rather than stale until the next scroll.</p>
<p><code>App::last_reframed_elsewhere</code> is the second trigger. Its opening frames are taken lazily inside <code>App::view_mut_of</code>, which is the one door such a write goes through, so the held-key path still hashes exactly one view: the wide-observation cost <a class="tid" href="../tickets.html#improvise-mcq4" title="The frame fingerprint is a 4x regression on the held-key path improvise-748f just optimized">improvise-mcq4</a> declined is not paid.</p>
<p><strong>Fault injected</strong>: the gate restored to <code>if !host_moved { return Vec::new(); }</code>. The test fails on <code>a client on the reframed view was never told</code>.</p>
<p>Two negative arms, and they are not decoration. A third client left on <code>Default</code> must keep its offset, or &quot;invalidate everyone&quot; passes; and <code>paging_with_nothing_pruned_invalidates_no_cursor</code> is what forbids relaying against <code>last_reframed_views</code>, which is a union with the <em>reshaped</em> set and so fires for a page selection that pruned nothing. That test failed during development and is why the elsewhere set is precise.</p>
<h3 id="a-command-naming-a-view-the-document-has-not-got-writes-nothing"><a class="anchor" href="#a-command-naming-a-view-the-document-has-not-got-writes-nothing">#</a>a_command_naming_a_view_the_document_has_not_got_writes_nothing</h3>
<p><code>App::resolve_view</code> falls back to the document&#39;s default for a name the document has not got, which is right for <em>reading</em> — a slice naming a deleted view must not panic in <code>rebuild_layout</code> — and wrong for <strong>writing</strong>, where it would move a view nobody asked about. <code>view_mut_of</code> therefore looks the name up directly and returns <code>None</code>.</p>
<p><strong>Fault injected</strong>: <code>view_mut_of</code>&#39;s <code>Named</code> arm routed through <code>self.resolve_view(name).0</code>. The test fails on <code>a name the document has not got fell back onto a view that has one</code>.</p>
<p>The window this closes is narrow — <code>Sessions::adopt_deleted_views</code> already moves a mirror off a deleted view and tells it — but it is a race, not an impossibility, and the silent alternative is a write to the wrong document state. Not closed here: the client hears nothing. A <code>Rejection</code> variant for it is filed rather than smuggled in, since <code>Rejection</code> is wire vocabulary.</p>
<h3 id="the-diff-tells-a-client-the-grid-became-a-records-grid"><a class="anchor" href="#the-diff-tells-a-client-the-grid-became-a-records-grid">#</a>the_diff_tells_a_client_the_grid_became_a_records_grid</h3>
<p>The producer half of <code>GridCache::records</code> (<a class="tid" href="../tickets.html#improvise-e5pf" title="Command::ChangeMode(Normal) is on the wire and reduce_view folds it without the records resolution">improvise-e5pf</a>). <code>grid_of</code> setting the field is exercised by <code>leaving_a_panel_in_records_mode_lands_where_the_host_lands</code>; the <strong>diff</strong> is a separate arm, and a client that connects before records mode is entered learns only through it.</p>
<p>Both preconditions are asserted — the fixture starts on an ordinary grid, and <code>R</code> really reaches a records one — so the comparison is between two answers that had something to disagree about.</p>
<p><strong>Fault injected</strong>: the <code>records</code> arm deleted from <code>grid_updates</code>. The test fails on <code>the client is never told</code>. The negative arm (an unchanged flag is not resent) is what stops &quot;always push it&quot; from passing.</p>
<h3 id="a-batch-that-leaves-records-settles-out-of-records-normal"><a class="anchor" href="#a-batch-that-leaves-records-settles-out-of-records-normal">#</a>a_batch_that_leaves_records_settles_out_of_records_normal</h3>
<p><a class="tid" href="../tickets.html#improvise-4dpj" title="The RecordsNormal-to-Normal arm of settle_normal_mode has zero coverage, and it is live">improvise-4dpj</a>, gap 1. <code>settle_normal_mode</code> matches <code>Normal | RecordsNormal</code>, and replacing that with a one-way <code>AppMode::Normal</code> left the <strong>entire workspace suite passing</strong>. The existing crossing test, <code>a_batch_that_leaves_records_and_then_sets_normal_lands_in_normal</code>, drives the layout both ways, but both of its branches end in <code>enter-mode normal</code>, so the mode handed to the settle is <code>AppMode::Normal</code> in both directions. It discriminates &quot;settles at all&quot; and &quot;settles against the ending layout&quot;, and not &quot;settles from <strong>either</strong> mode&quot;.</p>
<p>This drives the production repro: from a records grid, <code>apply_effects([SetAxis { &quot;_Index&quot;, Axis::None }])</code> leaves <code>layout.is_records_mode()</code> false with the mode still <code>RecordsNormal</code>, so the <code>RecordsNormal</code> arm is live and not defensive. Both the records-ness of the grid and the mode before the batch are asserted as preconditions, so a fixture that stopped entering records mode fails loudly rather than proving nothing.</p>
<p>Injected fault: <code>if matches!(self.view_state.mode, AppMode::Normal)</code>. Fails by name, <code>left: RecordsNormal, right: Normal</code>. It is the only test in the workspace that fails.</p>
<h3 id="switching-views-across-records-ness-settles-against-the-view-switched-to"><a class="anchor" href="#switching-views-across-records-ness-settles-against-the-view-switched-to">#</a>switching_views_across_records_ness_settles_against_the_view_switched_to</h3>
<p><a class="tid" href="../tickets.html#improvise-4dpj" title="The RecordsNormal-to-Normal arm of settle_normal_mode has zero coverage, and it is live">improvise-4dpj</a>, gap 2 — raised by ChatGPT Codex against PR #91 and verified correct on <code>main</code> before being tested. <code>SwitchViewAtCursor</code> emits <code>[SwitchView(target), ChangeMode(Normal)]</code>, and this is a <strong>different path</strong> from the one gap 1&#39;s siblings drive: that test toggles records mode explicitly, where a view switch crosses records-ness only because it is auto-detected from the axes (gotcha 1). Nothing in the batch says &quot;records&quot;.</p>
<p>Driven in both directions, with the crossing asserted before and after so a fixture that stopped crossing fails rather than passes. Injected fault: move <code>settle_normal_mode()</code> back above <code>rebuild_layout()</code> at the end of <code>apply_effects</code> — the exact regression PR #91&#39;s round 2 fixed. This test fails by name; so does gap 1&#39;s, along with six pre-existing records-mode tests (measured in review; an earlier draft of this entry said five).</p>
<p><strong>Scope note, stated because an overclaim is worse than a gap</strong>: this drives the <em>effects</em> <code>SwitchViewAtCursor</code> emits, not the view-panel key path that composes them. Reaching the panel needs a <code>view_panel_cursor</code> on a <code>CmdContext</code> built in <code>command/cmd/panel.rs</code>, which is another wave&#39;s ground this round. The command&#39;s own coverage lives there; what is unexercised is the two joined.</p>
<h3 id="the-layout-geometry-cases-are-every-wire-effect-of-that-kind"><a class="anchor" href="#the-layout-geometry-cases-are-every-wire-effect-of-that-kind">#</a>the_layout_geometry_cases_are_every_wire_effect_of_that_kind</h3>
<p><a class="tid" href="../tickets.html#improvise-26dm" title="The fourth length-not-completeness list: cases.len() == 13 in ui/effect.rs">improvise-26dm</a>. <code>layout_geometry_effects_actually_change_document_config</code> was guarded by <code>assert_eq!(cases.len(), 13)</code>, which catches a case being <em>deleted</em> from the list and never one being <em>absent</em> from it — and the two are written identically as an <code>assert_eq</code> over a number. The adversarial review of PR #78 added a fourteenth <code>pub struct</code> declaring <code>LayoutGeometry</code>, pointed at by nothing, and the whole suite stayed green.</p>
<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>&#39;s mechanism cannot reach it: an effect is a struct implementing a trait, so serde has no derived enumeration to diff against. <code>model_effects</code> maps each <code>ModelCommand</code> variant back to the effect it came from (gotcha 38), so the list is diffed against the <code>LayoutGeometry</code> effects reachable through <code>ModelCommand</code>. The two sets are equal today, which is the evidence the substitution lost nothing.</p>
<p><strong>The right-hand side is bounded by the derive, and that took a second round.</strong> Review of PR #96 injected the deletion of <code>ModelCommand::TogglePruneEmpty</code> from <code>every_model_command()</code>: this was the <strong>only</strong> test in the workspace that failed, and <code>every_model_command</code>&#39;s doc comment claiming the list was compiler-forced was wrong — <code>variant_name</code>&#39;s exhaustive match forces an arm <em>there</em>, not an entry in the list. ChatGPT Codex raised the same thing on the PR. <code>the_enumeration_names_every_variant_the_derive_declares</code> closes it, so the chain is derive → enumeration → <code>model_effects</code> → case list with every link checked. The assertion here still names <strong>both</strong> directions, because a short right-hand side reads as a spurious case on the left and invites deleting it — degrading both sides together.</p>
<p>Injected fault: drop <code>TogglePruneEmpty</code> from <code>layout_geometry_cases()</code>. The old count said <code>12 != 13</code>; this says <code>right: [… &quot;TogglePruneEmpty&quot; …]</code> and <strong>names the effect</strong>.</p>
<p><strong>Residual, stated rather than closed</strong>: this is total over the wire and not over the effects. An effect declaring <code>LayoutGeometry</code> that no <code>ModelCommand</code> names is still invisible — which is precisely PR #78&#39;s probe. Closing that needs an enumeration of effects the compiler maintains, and none exists without a proc macro or inventory-style registration.</p>
<h3 id="the-enumeration-names-every-variant-the-derive-declares"><a class="anchor" href="#the-enumeration-names-every-variant-the-derive-declares">#</a>the_enumeration_names_every_variant_the_derive_declares</h3>
<p>The bound the entry above rests on. Injection is Codex&#39;s own, run end to end: a <code>ModelCommand::ProbeGeometry</code> whose effect declares <code>LayoutGeometry</code>, with the <code>model_effects</code> and <code>variant_name</code> arms the compiler forces, omitted from <strong>both</strong> lists. Before this test, the whole workspace stayed green. After it, the failure is staged and each stage names the probe — first <code>every_model_command() never constructs [&quot;ProbeGeometry&quot;]</code>, then, once the enumeration is repaired and only the case list is short, <code>the_layout_geometry_cases_are_every_wire_effect_of_that_kind</code> with <code>ProbeGeometry</code> on the right. That staging is the point: the two tests answer different questions and the injection separates them.</p>
<p>The name reader is shared, not copied: <code>reduce.rs</code>&#39;s <code>effect_type_name</code> and its own <code>the_effect_name_reader_reads_all_three_debug_shapes</code> are what this leans on, because a second reader here would be a derivation done twice and could drift while one copy was repaired. It reads the leading identifier off the derived <code>Debug</code>, <code>Effect</code> being a trait object that <code>type_name</code> cannot reach.</p>
</main>
</div>
</body>
</html>