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

522 lines
222 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>The clients — 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">improvise-command</a></li><li><a href="../test-evidence/clients.html" class="here">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/clients.md">context/test-evidence/clients.md</a></p><details class="toc" open><summary>On this page</summary><ul><li class="l1"><a href="#test-evidence-browser-client-hosts-harness">Test evidence: browser client, hosts, harness</a></li><li class="l2"><a href="#improvise-worker-server-src-worker-rs">improvise-worker-server/src/worker.rs</a></li><li class="l3"><a href="#an-edit-is-persisted-without-the-client-asking-to-save">an_edit_is_persisted_without_the_client_asking_to_save</a></li><li class="l3"><a href="#a-partly-refused-batch-does-not-leave-disk-and-memory-disagreeing">a_partly_refused_batch_does_not_leave_disk_and_memory_disagreeing</a></li><li class="l3"><a href="#an-explicit-save-is-not-retried-by-the-automatic-write">an_explicit_save_is_not_retried_by_the_automatic_write</a></li><li class="l3"><a href="#a-key-resolved-on-the-host-moves-state-no-client-can-hear">a_key_resolved_on_the_host_moves_state_no_client_can_hear</a></li><li class="l2"><a href="#improvise-worker-server-src-vfs-rs">improvise-worker-server/src/vfs.rs</a></li><li class="l3"><a href="#an-entry-that-holds-no-document-is-seeded-over">an_entry_that_holds_no_document_is_seeded_over</a></li><li class="l2"><a href="#improvise-ws-server">improvise-ws-server</a></li><li class="l3"><a href="#distinct-stems-both-open">distinct_stems_both_open</a></li><li class="l3"><a href="#a-bystander-on-the-same-document-is-projected-the-edit">a_bystander_on_the_same_document_is_projected_the_edit</a></li><li class="l3"><a href="#the-other-document-would-also-have-a-cursor-to-invalidate">the_other_document_would_also_have_a_cursor_to_invalidate</a></li><li class="l3"><a href="#a-load-that-fails-rebases-nobody">a_load_that_fails_rebases_nobody</a></li><li class="l3"><a href="#a-reopened-document-would-have-lost-the-edit-and-the-basis">a_reopened_document_would_have_lost_the_edit_and_the_basis</a></li><li class="l3"><a href="#a-rebase-stops-at-the-document-boundary">a_rebase_stops_at_the_document_boundary</a></li><li class="l3"><a href="#a-client-on-a-socket-is-seeded-and-gets-its-edit-back">a_client_on_a_socket_is_seeded_and_gets_its_edit_back</a></li><li class="l2"><a href="#improvise-wasm-client-src-keys-rs">improvise-wasm-client/src/keys.rs</a></li><li class="l3"><a href="#a-modified-character-now-types-because-the-fallback-is-the-shared-one">a_modified_character_now_types_because_the_fallback_is_the_shared_one</a></li><li class="l3"><a href="#search-typing-writes-the-query-and-not-a-buffer">search_typing_writes_the_query_and_not_a_buffer</a></li><li class="l3"><a href="#the-command-names-that-still-need-the-host-are-these">the_command_names_that_still_need_the_host_are_these</a></li><li class="l3"><a href="#a-prompt-this-client-cannot-commit-is-never-entered">a_prompt_this_client_cannot_commit_is_never_entered</a></li><li class="l3"><a href="#the-command-line-cannot-be-committed-by-this-client">the_command_line_cannot_be_committed_by_this_client</a></li><li class="l3"><a href="#a-commit-in-the-last-cell-exits-editing-instead-of-advancing">a_commit_in_the_last_cell_exits_editing_instead_of_advancing</a></li><li class="l3"><a href="#a-move-that-goes-nowhere-follows-the-command-it-mirrors">a_move_that_goes_nowhere_follows_the_command_it_mirrors</a></li><li class="l3"><a href="#i-on-an-aggregate-declines-out-loud">i_on_an_aggregate_declines_out_loud</a></li><li class="l3"><a href="#enter-commits-the-cell-advances-and-re-enters-editing">enter_commits_the_cell_advances_and_re_enters_editing</a></li><li class="l2"><a href="#improvise-wasm-client-lib-socket-state-render">improvise-wasm-client — lib, socket, state, render</a></li><li class="l3"><a href="#a-client-with-no-basis-composes-nothing">a_client_with_no_basis_composes_nothing</a></li><li class="l3"><a href="#a-refused-batch-is-kept-as-a-rejection-and-not-only-as-a-sentence">a_refused_batch_is_kept_as_a_rejection_and_not_only_as_a_sentence</a></li><li class="l3"><a href="#every-rejection-message-says-what-it-refused">every_rejection_message_says_what_it_refused</a></li><li class="l3"><a href="#a-typed-string-composes-the-frame-that-carries-it">a_typed_string_composes_the_frame_that_carries_it</a></li><li class="l3"><a href="#a-cell-projection-that-overtakes-its-labels-is-lost">a_cell_projection_that_overtakes_its_labels_is_lost</a></li><li class="l3"><a href="#bootstrapping-from-a-compaction-lands-where-the-stream-lands">bootstrapping_from_a_compaction_lands_where_the_stream_lands</a></li><li class="l3"><a href="#the-selection-is-placed-by-subtracting-the-scroll-offset">the_selection_is_placed_by_subtracting_the_scroll_offset</a></li><li class="l3"><a href="#column-groups-coalesce-per-header-as-the-tui-does">column_groups_coalesce_per_header_as_the_tui_does</a></li><li class="l3"><a href="#a-keyboard-edit-in-progress-is-part-of-the-render-model">a_keyboard_edit_in_progress_is_part_of_the_render_model</a></li><li class="l2"><a href="#improvise-wasm-client-tests-browser-round-trip-rs">improvise-wasm-client/tests/browser_round_trip.rs</a></li><li class="l3"><a href="#a-grouped-row-axis-does-not-shift-the-write">a_grouped_row_axis_does_not_shift_the_write</a></li><li class="l3"><a href="#a-commit-keypress-is-one-mutation-among-view-commands-and-is-accepted">a_commit_keypress_is_one_mutation_among_view_commands_and_is_accepted</a></li><li class="l2"><a href="#improvise-wasm-client-tests-lockstep-rs">improvise-wasm-client/tests/lockstep.rs</a></li><li class="l3"><a href="#a-locally-typed-character-lands-where-the-host-would-put-it">a_locally_typed_character_lands_where_the_host_would_put_it</a></li><li class="l3"><a href="#a-forwarded-key-reaches-the-host-unchanged">a_forwarded_key_reaches_the_host_unchanged</a></li><li class="l3"><a href="#a-locally-resolved-cursor-move-lands-where-the-host-would-put-it">a_locally_resolved_cursor_move_lands_where_the_host_would_put_it</a></li><li class="l3"><a href="#a-locally-resolved-panel-cursor-agrees-with-the-host">a_locally_resolved_panel_cursor_agrees_with_the_host</a></li><li class="l3"><a href="#the-buffer-i-seeds-is-the-value-the-host-would-seed">the_buffer_i_seeds_is_the_value_the_host_would_seed</a></li><li class="l3"><a href="#entering-typing-and-cancelling-an-edit-agrees-with-the-host">entering_typing_and_cancelling_an_edit_agrees_with_the_host</a></li><li class="l3"><a href="#a-locally-committed-edit-leaves-both-ends-in-the-same-place">a_locally_committed_edit_leaves_both_ends_in_the_same_place</a></li><li class="l2"><a href="#harness-the-browser-specs">harness/ — the browser specs</a></li><li class="l3"><a href="#a-locally-cycled-panel-focus-agrees-with-the-host">a_locally_cycled_panel_focus_agrees_with_the_host</a></li><li class="l3"><a href="#a-locally-resolved-tile-axis-change-agrees-with-the-host">a_locally_resolved_tile_axis_change_agrees_with_the_host</a></li><li class="l3"><a href="#the-category-panel-bindings-agree-with-the-host">the_category_panel_bindings_agree_with_the_host</a></li><li class="l3"><a href="#the-view-panel-bindings-agree-with-the-host">the_view_panel_bindings_agree_with_the_host</a></li><li class="l3"><a href="#a-locally-committed-minibuffer-agrees-with-the-host">a_locally_committed_minibuffer_agrees_with_the_host</a></li><li class="l3"><a href="#a-formula-the-client-cannot-parse-diverges-on-the-status-line">a_formula_the_client_cannot_parse_diverges_on_the_status_line</a></li><li class="l3"><a href="#panel-focus-cycles-from-the-focused-panel-not-the-first">panel_focus_cycles_from_the_focused_panel_not_the_first</a></li><li class="l3"><a href="#a-locally-resolved-clear-and-paste-agree-with-the-host">a_locally_resolved_clear_and_paste_agree_with_the_host</a></li><li class="l3"><a href="#walking-off-the-end-of-the-help-pages-agrees-with-the-host">walking_off_the_end_of_the_help_pages_agrees_with_the_host</a></li><li class="l3"><a href="#the-shipped-demo-seeds-a-worker-whose-raw-cells-are-settable">the_shipped_demo_seeds_a_worker_whose_raw_cells_are_settable</a></li><li class="l3"><a href="#the-page-keys-turn-the-odometer-as-the-command-does">the_page_keys_turn_the_odometer_as_the_command_does</a></li><li class="l3"><a href="#a-filter-axis-is-projected-but-not-cycled">a_filter_axis_is_projected_but_not_cycled</a></li><li class="l3"><a href="#a-locally-resolved-page-turn-lands-where-the-host-would-put-it">a_locally_resolved_page_turn_lands_where_the_host_would_put_it</a></li><li class="l3"><a href="#a-click-focuses-the-panel-moves-its-cursor-and-activates-the-row">a_click_focuses_the_panel_moves_its_cursor_and_activates_the_row</a></li><li class="l3"><a href="#a-cell-click-is-absolute-even-though-the-page-paints-a-window">a_cell_click_is_absolute_even_though_the_page_paints_a_window</a></li><li class="l3"><a href="#a-click-past-the-last-row-resolves-to-nothing">a_click_past_the_last_row_resolves_to_nothing</a></li><li class="l3"><a href="#the-drill-key-and-an-aggregate-edit-refuse-for-different-reasons">the_drill_key_and_an_aggregate_edit_refuse_for_different_reasons</a></li><li class="l3"><a href="#a-view-row-click-focuses-moves-and-switches-to-the-row-clicked">a_view_row_click_focuses_moves_and_switches_to_the_row_clicked</a></li><li class="l3"><a href="#a-formula-row-click-navigates-and-activates-nothing">a_formula_row_click_navigates_and_activates_nothing</a></li><li class="l3"><a href="#the-formula-panel-has-no-enter-to-mirror-and-the-view-panel-does">the_formula_panel_has_no_enter_to_mirror_and_the_view_panel_does</a></li><li class="l3"><a href="#a-click-that-cannot-compose-its-activation-composes-nothing">a_click_that_cannot_compose_its_activation_composes_nothing</a></li><li class="l3"><a href="#the-same-click-composes-everything-once-a-basis-is-published">the_same_click_composes_everything_once_a_basis_is_published</a></li><li class="l3"><a href="#a-first-tile-click-selects-without-touching-the-document">a_first_tile_click_selects_without_touching_the_document</a></li><li class="l3"><a href="#t-then-a-click-on-the-leftmost-tile-cycles-it-immediately">t_then_a_click_on_the_leftmost_tile_cycles_it_immediately</a></li><li class="l3"><a href="#enter-in-an-empty-view-panel-resolves-locally-rather-than-upstream">enter_in_an_empty_view_panel_resolves_locally_rather_than_upstream</a></li><li class="l3"><a href="#the-first-click-on-tile-zero-selects-it-like-any-other">the_first_click_on_tile_zero_selects_it_like_any_other</a></li><li class="l3"><a href="#clicking-a-different-tile-selects-it-rather-than-cycling-it">clicking_a_different_tile_selects_it_rather_than_cycling_it</a></li><li class="l3"><a href="#the-terminal-constructor-inherits-the-shared-default-rather-than-setting-it">the_terminal_constructor_inherits_the_shared_default_rather_than_setting_it</a></li><li class="l3"><a href="#the-terminal-app-launches-with-the-category-panel-open">the_terminal_app_launches_with_the_category_panel_open</a></li><li class="l3"><a href="#set-panel-open-and-cursor">set_panel_open_and_cursor</a></li><li class="l3"><a href="#a-panel-command-writes-that-panel-and-no-other">a_panel_command_writes_that_panel_and_no_other</a></li><li class="l3"><a href="#a-fresh-session-opens-with-the-category-panel">a_fresh_session_opens_with_the_category_panel</a></li><li class="l2"><a href="#web-tests-the-node-suite-and-what-was-injected-into-it">web/tests/ — the Node suite, and what was injected into it</a></li><li class="l2"><a href="#harness-tests-contract-spec-js-eight-injections-into-the-served-page">harness/tests/contract.spec.js — eight injections into the served page</a></li><li class="l3"><a href="#every-advertised-command-answers-in-its-own-mode">every_advertised_command_answers_in_its_own_mode</a></li><li class="l3"><a href="#a-locally-committed-creating-column-agrees-with-the-host">a_locally_committed_creating_column_agrees_with_the_host</a></li><li class="l3"><a href="#every-minibuffer-mode-paints-what-is-being-typed">every_minibuffer_mode_paints_what_is_being_typed</a></li><li class="l3"><a href="#nothing-declined-is-also-composed">nothing_declined_is_also_composed</a></li><li class="l2"><a href="#harness-tests-help-spec-js-the-overlay-asserted-as-words">harness/tests/help.spec.js — the overlay, asserted as words</a></li><li class="l2"><a href="#harness-tests-grouped-spec-js-the-first-browser-run-of-improvise-anr9">harness/tests/grouped.spec.js — the first browser run of improvise-anr9</a></li><li class="l2"><a href="#harness-tests-landing-spec-js-the-front-door">harness/tests/landing.spec.js — the front door</a></li><li class="l2"><a href="#harness-tests-contract-spec-js-the-ninth-injection-and-a-lost-guard">harness/tests/contract.spec.js — the ninth injection, and a lost guard</a></li><li class="l3"><a href="#the-keys-that-compose-a-switch-view-are-these">the_keys_that_compose_a_switch_view_are_these</a></li><li class="l2"><a href="#improvise-ws-server-the-origin-check-improvise-kc7n">improvise-ws-server — the Origin check (improvise-kc7n)</a></li><li class="l3"><a href="#a-handshake-from-a-foreign-origin-is-refused">a_handshake_from_a_foreign_origin_is_refused</a></li><li class="l3"><a href="#a-handshake-with-no-origin-is-accepted">a_handshake_with_no_origin_is_accepted</a></li><li class="l3"><a href="#a-handshake-from-a-loopback-page-is-accepted">a_handshake_from_a_loopback_page_is_accepted</a></li><li class="l3"><a href="#a-named-origin-reaches-the-document">a_named_origin_reaches_the_document</a></li><li class="l3"><a href="#allow-origin-widens-the-policy-and-nothing-else-does">allow_origin_widens_the_policy_and_nothing_else_does</a></li><li class="l3"><a href="#two-origins-state-none-this-policy-can-answer-for">two_origins_state_none_this_policy_can_answer_for</a></li><li class="l3"><a href="#a-refusal-is-a-forbidden-response">a_refusal_is_a_forbidden_response</a></li><li class="l3"><a href="#the-fixtures-basis-tracks-a-host-whose-layout-has-moved">the_fixtures_basis_tracks_a_host_whose_layout_has_moved</a></li><li class="l3"><a href="#leaving-a-panel-in-records-mode-lands-where-the-host-lands">leaving_a_panel_in_records_mode_lands_where_the_host_lands</a></li><li class="l3"><a href="#a-records-commit-is-declined-off-the-grid-and-not-off-the-mode">a_records_commit_is_declined_off_the_grid_and_not_off_the_mode</a></li><li class="l3"><a href="#the-normal-pair-is-settled-in-both-directions">the_normal_pair_is_settled_in_both_directions</a></li><li class="l3"><a href="#switching-between-a-records-view-and-a-pivot-view-settles-both-ways">switching_between_a_records_view_and_a_pivot_view_settles_both_ways</a></li><li class="l3"><a href="#the-mode-a-switch-lands-in-is-the-one-a-host-on-that-view-holds">the_mode_a_switch_lands_in_is_the_one_a_host_on_that_view_holds</a></li></ul></details><h1 id="test-evidence-browser-client-hosts-harness">Test evidence: browser client, hosts, harness</h1>
<p>What each of these tests proves, what fault was injected to prove it can fail, and — where it applies — the bug it was written against. Keyed by exact test name; a renamed test orphans its entry, which <code>scripts/check-comment-runs.py --check-evidence</code> reports.</p>
<p>The design argument each of these belongs to is in <a href="../code/clients.html"><code>context/code/clients.md</code></a>.</p>
<hr />
<h2 id="improvise-worker-server-src-worker-rs"><a class="anchor" href="#improvise-worker-server-src-worker-rs">#</a><code>improvise-worker-server/src/worker.rs</code></h2>
<h3 id="an-edit-is-persisted-without-the-client-asking-to-save"><a class="anchor" href="#an-edit-is-persisted-without-the-client-asking-to-save">#</a>an_edit_is_persisted_without_the_client_asking_to_save</h3>
<p>The persistence half of <a class="tid" href="../tickets.html#improvise-9x6" title="Worker-hosted server bundle (wasm, MessageChannel transport)">improvise-9x6</a>&#39;s acceptance criterion 2, and the reason the worker saves on its own behalf: there is no <code>ModelCommand::Save</code> for a client to send, because <code>effect::Save</code> is <code>ViewOnly</code> and the wire vocabulary is derived from the <em>model</em> effects. So an edit must reach storage with the client having asked for nothing but the edit. The test writes one cell over the wire and reopens the document through the same VFS.</p>
<h3 id="a-partly-refused-batch-does-not-leave-disk-and-memory-disagreeing"><a class="anchor" href="#a-partly-refused-batch-does-not-leave-disk-and-memory-disagreeing">#</a>a_partly_refused_batch_does_not_leave_disk_and_memory_disagreeing</h3>
<p><a class="tid" href="../tickets.html#improvise-955n" title="[bug] Host::commands applies a batch partially before rejecting">improvise-955n</a>&#39;s <strong>second-order effect</strong>, which nobody had written down until it was found while declining the ticket: <code>on_frame</code> skipped <code>persist()</code> when any frame in the answer was <code>Rejected</code>, so a batch that <em>did</em> apply its earlier command before being refused left the document moved in memory and not written back — disk and worker disagreeing, silently.</p>
<p>Two changes close it independently, which is why this is worth a test of its own rather than trusting either: the batch no longer applies partially at all (<a class="tid" href="../tickets.html#improvise-955n" title="[bug] Host::commands applies a batch partially before rejecting">improvise-955n</a>), and the persist trigger is now the document&#39;s own dirtiness rather than &quot;was anything rejected&quot; (<a class="tid" href="../tickets.html#improvise-gv9r" title="[bug] A wire model command never marks the document dirty">improvise-gv9r</a>), so a batch that <em>did</em> change something would be written whatever else the answer contained.</p>
<p>The fixture is a refused frame with a command behind the refusal, and the assertion is that the axes on disk and the axes in memory agree. Under the settled contract the refusal comes from the <strong>frame&#39;s</strong> basis — answered once, on the first command that carries one — so that is where the staleness is injected; a later command carries no answer of its own.</p>
<h3 id="an-explicit-save-is-not-retried-by-the-automatic-write"><a class="anchor" href="#an-explicit-save-is-not-retried-by-the-automatic-write">#</a>an_explicit_save_is_not_retried_by_the_automatic_write</h3>
<p>Found by Copilot on PR #26. A failed write leaves <code>model_state.dirty</code> set — deliberately, so the next frame retries — and the automatic trigger runs after <em>every</em> frame. A save frame therefore attempted the same write twice: extra I/O on a permanent failure, and on a transient one something worse, because the answer is composed from the <em>first</em> attempt, so the client is told <code>SaveFailed</code> about a document that is now on disk.</p>
<p>The fix is a per-frame question rather than a per-document one, and that is the right shape precisely because the automatic write exists for edits <strong>nobody asked</strong> to save; one that was asked for has already been answered.</p>
<p>The fixture is a <code>Storage</code> backend whose writes always fail and that counts them. <strong>Counting is the point</strong>: &quot;it was refused&quot; is true whether the write was attempted once or twice, so a test asserting only the refusal would pass against the bug. <strong>Failing is the point too</strong>: a successful save clears <code>dirty</code>, so the redundant second attempt never happens and the bug is invisible from the happy path.</p>
<h3 id="a-key-resolved-on-the-host-moves-state-no-client-can-hear"><a class="anchor" href="#a-key-resolved-on-the-host-moves-state-no-client-can-hear">#</a>a_key_resolved_on_the_host_moves_state_no_client_can_hear</h3>
<p><em>Why</em> the <code>Key</code> arm answers nothing, demonstrated rather than asserted in prose.</p>
<p>A worker can call <code>reduce_key</code> — it holds the authoritative <code>App</code>, which is the thing a socket server lacks. What neither has is a way to tell a client that the <strong>host&#39;s own <code>ViewState</code></strong> moved: a client&#39;s slice advances only by folding <code>Command</code>s, and resolving a keypress produces none. So the two slices diverge, and <code>ClientSession::viewport</code> goes on reading the mirror&#39;s cursor, which means the grid does not even follow. That is <a class="tid" href="../tickets.html#improvise-bpq2" title="Client-side key resolution: what must the projections carry?">improvise-bpq2</a>&#39;s real cost, and it is a ticket rather than plumbing. The test asserts all three: the authoritative cursor moved, the mirror did not, and nothing <code>reduce_key</code> emits would let a client catch up.</p>
<hr />
<h2 id="improvise-worker-server-src-vfs-rs"><a class="anchor" href="#improvise-worker-server-src-vfs-rs">#</a><code>improvise-worker-server/src/vfs.rs</code></h2>
<h3 id="an-entry-that-holds-no-document-is-seeded-over"><a class="anchor" href="#an-entry-that-holds-no-document-is-seeded-over">#</a>an_entry_that_holds_no_document_is_seeded_over</h3>
<p>The first browser run of the deployed page failed here, and no host test could see it: <code>attach(create: true)</code> leaves a <strong>zero-byte</strong> entry behind, <code>documents</code> listed it, and <code>open</code> therefore took the seed as already applied and read an empty file. The precondition is asserted before the act, because a test that only opens cannot tell &quot;the seed landed&quot; from &quot;there was nothing in the way&quot;. The rule it pins is the one gotcha 45 states for the index: what is indexed is a document, and an entry nobody has written bytes to is not one.</p>
<p>This entry exists because the test cites this file and <code>--check-evidence</code> now requires that (<a class="tid" href="../tickets.html#improvise-lyyc" title="check-evidence is one-directional: a test citing the evidence file can have no entry">improvise-lyyc</a>); it was written from the test, so read the test first if the two ever disagree.</p>
<hr />
<h2 id="improvise-ws-server"><a class="anchor" href="#improvise-ws-server">#</a><code>improvise-ws-server</code></h2>
<h3 id="distinct-stems-both-open"><a class="anchor" href="#distinct-stems-both-open">#</a>distinct_stems_both_open</h3>
<p><code>doctrine(check_the_checker)</code> for <code>colliding_stems_are_fatal</code>: inject the fault that test rules out — distinct stems — and both documents must open. Otherwise an <code>open_all</code> that failed on every second file would pass the collision test.</p>
<h3 id="a-bystander-on-the-same-document-is-projected-the-edit"><a class="anchor" href="#a-bystander-on-the-same-document-is-projected-the-edit">#</a>a_bystander_on_the_same_document_is_projected_the_edit</h3>
<p><code>doctrine(check_the_checker)</code> for <code>an_edit_is_projected_only_to_the_edited_documents_clients</code>: put the bystander on the <em>same</em> document and the assertion must flip. Without it, an isolation test that passed because nobody is ever projected anything looks identical.</p>
<h3 id="the-other-document-would-also-have-a-cursor-to-invalidate"><a class="anchor" href="#the-other-document-would-also-have-a-cursor-to-invalidate">#</a>the_other_document_would_also_have_a_cursor_to_invalidate</h3>
<p><code>doctrine(check_the_checker)</code> for <code>a_broadcast_does_not_cross_a_document_boundary</code>. The fault to rule out is the other document being <em>incapable</em> of producing the broadcast at all — then a server-wide relay would be silent over there for the wrong reason and the isolation test would pass without proving isolation. So the checker drives the same <code>TransposeAxes</code> against <code>beta</code> and asserts it does produce a <code>ResetCursor</code> there; <code>beta</code>&#39;s silence in the isolation test is the routing and nothing else.</p>
<p>Both tests moved from <code>Command::SwitchView</code> to <code>ModelCommand::TransposeAxes</code> with <a class="tid" href="../tickets.html#improvise-jytm" title="Split active_view: document default vs per-client authoritative (improvise-g0f)">improvise-jytm</a>: a view switch is now addressed to one session and relays nothing, so it can no longer carry an isolation test. <code>ResetCursor</code> is the surviving <code>Broadcast::ToAll</code>.</p>
<h3 id="a-load-that-fails-rebases-nobody"><a class="anchor" href="#a-load-that-fails-rebases-nobody">#</a>a_load_that_fails_rebases_nobody</h3>
<p><code>doctrine(check_the_checker)</code> for <code>a_rebase_stops_at_the_document_boundary</code>: inject a load that cannot happen. The generation does not move, so nobody is rebased — which is also what a routing bug that sent <code>Snapshot</code> to nobody would look like, so the rebase test needs this one beside it to mean anything.</p>
<h3 id="a-reopened-document-would-have-lost-the-edit-and-the-basis"><a class="anchor" href="#a-reopened-document-would-have-lost-the-edit-and-the-basis">#</a>a_reopened_document_would_have_lost_the_edit_and_the_basis</h3>
<p><code>doctrine(check_the_checker)</code> for <code>the_last_session_leaving_does_not_close_the_document</code>: inject the eviction — a document freshly opened from the same fixture, which is exactly what closing on last-disconnect would hand the next client. Both assertions in the lifecycle test fail against it, so neither passes for a reason other than the document having stayed put.</p>
<h3 id="a-rebase-stops-at-the-document-boundary"><a class="anchor" href="#a-rebase-stops-at-the-document-boundary">#</a>a_rebase_stops_at_the_document_boundary</h3>
<p>Fixture note, and it is gotcha 39&#39;s warning: the swapped-in document keeps the <strong>same active view name</strong> and changes only its shape. A rename would make <code>apply_model</code> broadcast a <code>SwitchView</code>, the client would fold it, and the stale cursor would then be read under a key nothing has written — right by accident, with the bug invisible.</p>
<h3 id="a-client-on-a-socket-is-seeded-and-gets-its-edit-back"><a class="anchor" href="#a-client-on-a-socket-is-seeded-and-gets-its-edit-back">#</a>a_client_on_a_socket_is_seeded_and_gets_its_edit_back</h3>
<p>(<a href="https://github.com/fiddlerwoaroof/improvise/blob/main/crates/improvise-ws-server/tests/socket.rs"><code>crates/improvise-ws-server/tests/socket.rs</code></a>, and its neighbours in that file.) Everything the socket test asserts is already asserted in <code>host.rs</code> without a socket, and deliberately so: its subject is the <strong>shell</strong>, not the decisions. It earns its place by covering what the unit tests structurally cannot — the handshake rule, JSON framing in both directions, the routing from a <code>SessionId</code> back to the connection it came from, and the one fact that lives only in a URL: which document the connection asked for. If it ever starts being the place a behaviour is pinned, that behaviour belongs in <code>host.rs</code> or <code>registry.rs</code>.</p>
<p>Two disciplines inside it are load-bearing rather than stylistic. The write is composed from the surrogate the server projected (ADR 002) — a <code>CellKey</code> there would be the test knowing something a browser does not — and the basis is composed from what the server published, because a basis the test built itself would be a client claiming to have seen something it was never sent.</p>
<hr />
<h2 id="improvise-wasm-client-src-keys-rs"><a class="anchor" href="#improvise-wasm-client-src-keys-rs">#</a><code>improvise-wasm-client/src/keys.rs</code></h2>
<h4 id="text-entry-modes-a-fixture-not-a-test"><a class="anchor" href="#text-entry-modes-a-fixture-not-a-test">#</a>text_entry_modes (a fixture, not a test)</h4>
<p>Not a test — a fixture, and its limitation is the evidence. The comment here used to claim a new mode &quot;shows up as a missing line rather than as untested behaviour&quot;. It does not: this is a hand-written list and nothing checks it against <code>AppMode</code>, so a new text-entry mode is simply absent and silently uncovered — which is what happened to <code>save_prompt</code> (<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>) until it was added by hand. There is a second hand-written copy in the command crate&#39;s <code>keymap::tests::text_entry_modes_with_exits</code>. Deriving both from one place is filed, not done.</p>
<h3 id="a-modified-character-now-types-because-the-fallback-is-the-shared-one"><a class="anchor" href="#a-modified-character-now-types-because-the-fallback-is-the-shared-one">#</a>a_modified_character_now_types_because_the_fallback_is_the_shared_one</h3>
<p>The old classifier sent a modified character upstream in a text-entry mode, because the host&#39;s <code>AnyChar</code> fallback matches under <strong>any</strong> modifier and a hand-written rule could only safely be a subset of it. Reading the same table removes the question: <code>Ctrl-x</code> falls through to <code>AnyChar</code> here exactly as it does on the host.</p>
<h3 id="search-typing-writes-the-query-and-not-a-buffer"><a class="anchor" href="#search-typing-writes-the-query-and-not-a-buffer">#</a>search_typing_writes_the_query_and_not_a_buffer</h3>
<p>Search typing resolves on the client for the first time, and only because the keymap is shared: <code>SearchMode</code> binds <code>append-char [&quot;search&quot;]</code>, and the name <code>&quot;search&quot;</code> is <code>ViewState::search_query</code> rather than an entry in <code>buffers</code> (<a class="tid" href="../tickets.html#improvise-gdl" title="SetBuffer effect special-cases the &quot;search&quot; buffer name">improvise-gdl</a>). The old classifier sent every search key upstream into an empty vec. The negative half of the test matters too: <code>Esc</code> stays upstream, because <code>exit-search-mode</code> owns the query and is the host&#39;s.</p>
<h3 id="the-command-names-that-still-need-the-host-are-these"><a class="anchor" href="#the-command-names-that-still-need-the-host-are-these">#</a>the_command_names_that_still_need_the_host_are_these</h3>
<p>The command <strong>names</strong> this client cannot execute, one by one, so that closing one of these gaps has to come here and say so. This replaced a list of <em>keys</em>, and that is the whole gain of sharing the keymap: a rebinding no longer invalidates the list, and a new binding for an already-executable command works in a browser with no change to this crate. Each entry is driven through the key that reaches it, because a name that is not bound anywhere would otherwise sit in the list forever looking like a gap.</p>
<h3 id="a-prompt-this-client-cannot-commit-is-never-entered"><a class="anchor" href="#a-prompt-this-client-cannot-commit-is-never-entered">#</a>a_prompt_this_client_cannot_commit_is_never_entered</h3>
<p>The refusal list above is only half the property. <code>Upstream</code> says the key produced no <code>Command</code>; it does not say the client stayed put, and the bug being guarded is precisely <em>entering a room with no exit</em>.</p>
<p>Found by Copilot on PR #27. Without <code>strands_the_user</code>, <code>Ctrl-W</code> resolved <code>enter-mode save-prompt</code> locally — <code>ChangeMode(SavePrompt)</code>, a perfectly good <code>Command</code> — and the user then typed a path into a prompt whose Enter goes nowhere. <code>Ctrl-E</code> did the same into <code>ExportPrompt</code> and had done since before that branch.</p>
<p><strong>The negative arm has two halves, and both are needed.</strong> Half one: a mode that is <em>not</em> refused is still entered — the half that goes through <code>strands_the_user</code>, so the only thing that can fail when the predicate degenerates to &quot;refuse everything&quot;. <code>?</code> is honest here where <code>:</code> was not: Help has no commit at all, so nothing a user types into it can be swallowed, and Esc closes it locally. (Its page keys <em>are</em> upstream, which makes page navigation inert — but inert navigation loses no work, which is the line the predicate draws.) Half two: the property is about <em>finishing</em>, not entering, so something has to press Enter and observe. The first version of this test asserted only that <code>:</code> entered its mode, and <code>:</code> is itself a dead room — so that arm was certifying the very behaviour it existed to rule out.</p>
<h3 id="the-command-line-cannot-be-committed-by-this-client"><a class="anchor" href="#the-command-line-cannot-be-committed-by-this-client">#</a>the_command_line_cannot_be_committed_by_this_client</h3>
<p>The evidence behind listing <code>CommandMode</code> in <code>strands_the_user</code>, rather than the assertion that it is listed. <code>:</code> opens locally and typing appends locally, but Enter is <code>[execute-command, clear-buffer]</code> and <code>step</code> has no <code>execute-command</code> arm — so the whole sequence fails to resolve, <code>Host::handle</code> drops the frame, and every ex-command behind <code>:</code> is inert. That is what makes refusing the mode cost no working surface. <strong>When <a class="tid" href="../tickets.html#improvise-e24v" title="Second tranche of client-side key resolution: ~15 bindings needing no enrichment">improvise-e24v</a> gives this client <code>execute-command</code>, this is the test that should start failing</strong>, and the <code>CommandMode</code> arm of <code>strands_the_user</code> is what it points at.</p>
<h3 id="a-commit-in-the-last-cell-exits-editing-instead-of-advancing"><a class="anchor" href="#a-commit-in-the-last-cell-exits-editing-instead-of-advancing">#</a>a_commit_in_the_last_cell_exits_editing_instead_of_advancing</h3>
<p>At the bottom-right corner <code>EnterAdvance</code> returns <code>AbortChain</code>, so the re-entry is skipped and the commit leaves the user in normal mode. That abort is a fact about the host&#39;s effect chain and it has to be mirrored, or the browser would sit in an editor on a cell the terminal has left. The fixture is a one-by-one grid, so the only cell <em>is</em> the corner.</p>
<h3 id="a-move-that-goes-nowhere-follows-the-command-it-mirrors"><a class="anchor" href="#a-move-that-goes-nowhere-follows-the-command-it-mirrors">#</a>a_move_that_goes_nowhere_follows_the_command_it_mirrors</h3>
<p>Which widgets say nothing about a move that goes nowhere is <strong>not a rule of <code>keys.rs</code></strong> — it is whatever the <code>Cmd</code> being mirrored does, and the three differ: <code>Move</code> emits even when it clamps, <code>MovePanelCursor</code> returns nothing when the cursor does not move, <code>MoveTileCursor</code> emits regardless. The grid arm carries its own check-the-checker inline (a <code>j</code> that does move), so &quot;emits when clamped&quot; cannot pass against a fixture that is simply inert.</p>
<h3 id="i-on-an-aggregate-declines-out-loud"><a class="anchor" href="#i-on-an-aggregate-declines-out-loud">#</a>i_on_an_aggregate_declines_out_loud</h3>
<p><code>i</code> on an aggregate must not go upstream: <code>Host::handle</code> answers a <code>Key</code> frame with an empty vec, so that would be a keypress the user sees do nothing at all (<code>doctrine(an_upstream_key_is_a_black_hole)</code>).</p>
<h3 id="enter-commits-the-cell-advances-and-re-enters-editing"><a class="anchor" href="#enter-commits-the-cell-advances-and-re-enters-editing">#</a>enter_commits_the_cell_advances_and_re_enters_editing</h3>
<p>Enter in <code>Editing</code> is <code>[commit-cell-edit editing, clear-buffer edit]</code>, and every part of that sequence shows up in what the client composes — <strong>including the trailing clear that empties the buffer <code>EnterEditAtCursor</code> has just seeded</strong>. That ordering looks wrong and is exactly what the TUI does; mirroring it line for line is the contract.</p>
<hr />
<h2 id="improvise-wasm-client-lib-socket-state-render"><a class="anchor" href="#improvise-wasm-client-lib-socket-state-render">#</a><code>improvise-wasm-client</code> — lib, socket, state, render</h2>
<h3 id="a-client-with-no-basis-composes-nothing"><a class="anchor" href="#a-client-with-no-basis-composes-nothing">#</a>a_client_with_no_basis_composes_nothing</h3>
<p>The fourth <code>None</code>, and the one ADR 001 adds: a client that has not been told the document&#39;s basis has nothing truthful to say about what it composed against, so it composes nothing. That is the property that makes the basis check unforgeable from a browser — <code>BasisCache::claim</code> is the only constructor, and it withholds.</p>
<p>The other three <code>None</code> shapes — an aggregate, <code>id: None</code>, and a position outside the grid — are pinned against the same composition in <code>socket.rs</code>&#39;s <code>typing_into_a_cell_that_is_not_a_record_composes_nothing</code>. They were asserted twice, once against each of two compositions, until <a class="tid" href="../tickets.html#improvise-r4lo" title="Two answers to what a typed string means, and they are adjacent">improvise-r4lo</a> left only one.</p>
<h3 id="a-refused-batch-is-kept-as-a-rejection-and-not-only-as-a-sentence"><a class="anchor" href="#a-refused-batch-is-kept-as-a-rejection-and-not-only-as-a-sentence">#</a>a_refused_batch_is_kept_as_a_rejection_and_not_only_as_a_sentence</h3>
<p>A refused batch must reach the person who composed it, and it must reach them as more than a sentence. ADR 001 makes rejection a <em>normal outcome</em>, and the obligation that comes with it is that the client keeps the typing and can re-compose. Force needs the <code>Rejection</code> itself — <code>RecordGone</code> cannot be forced at all where <code>RecordMoved</code> can — so collapsing it into <code>status_msg</code> would throw away the input to the very feature rejection exists to enable. <strong>Both halves are asserted, because setting the string alone is the plausible half-fix</strong>, and the revision bump is asserted too: a renderer polls the revision, and a rejection it cannot see is a rejection it cannot draw.</p>
<h3 id="every-rejection-message-says-what-it-refused"><a class="anchor" href="#every-rejection-message-says-what-it-refused">#</a>every_rejection_message_says_what_it_refused</h3>
<p>Found by the PR #12 Copilot review: <code>DocumentReplaced</code> read &quot;the document was replaced — reloading&quot;, and nothing in <code>fold_frame</code> reloads anything. <strong>A message that names an action the code does not take is worse than a vague one, because the user waits for it.</strong></p>
<p>Renamed and re-scoped by <a class="tid" href="../tickets.html#improvise-8uj8" title="every_rejection_message_says_the_edit_did_not_land covers five of seven variants">improvise-8uj8</a>, which is the more interesting half. The test was called <code>every_rejection_message_says_the_edit_did_not_land</code> and its own doc said the fact &quot;is asserted over all of them rather than one at a time&quot; — while it drove a <strong>hand-written list of five</strong>, written before PR #26 added <code>NoDocumentPath</code> and <code>SaveFailed</code>. So two variants were silently exempted from a rule the test called universal, which is worse than no test: the claim was false about its own subject.</p>
<p>The re-scope is that the claim, not the messages, was wrong. A save failure did not fail to <em>apply</em> anything, so &quot;the edit did not land&quot; is the wrong sentence for it. <code>Rejection::refused_an_edit</code> (exhaustive) splits the two classes and <code>Rejection::all</code> (a cyclic walk, the same shape <code>AppMode::all</code> uses) drives them, so a new variant fails the build rather than slipping out of the list.</p>
<p>Fault injected: wording <code>SaveFailed</code> as <code>&quot;hmm&quot;</code> fails the test — the arm the old five-item list could not have seen. Adding a <code>Rejection</code> variant fails the build at <code>basis.rs</code>&#39;s <code>refused_an_edit</code> and <code>after</code>.</p>
<h3 id="a-typed-string-composes-the-frame-that-carries-it"><a class="anchor" href="#a-typed-string-composes-the-frame-that-carries-it">#</a>a_typed_string_composes_the_frame_that_carries_it</h3>
<p>Was <code>a_typed_string_is_classified_as_the_host_classifies_it</code>, a test that <strong>stated an obligation in its name and did not discharge it</strong> — it asserted three outcomes by hand rather than comparing against the host&#39;s function, so the two copies of the rule could drift and stay green. <a class="tid" href="../tickets.html#improvise-j7v6" title="The typed-string classification lives in two crates, and its guard test asserts by hand">improvise-j7v6</a> removed the duplication rather than the drift: <code>commit_regular_cell_value</code> now calls <code>improvise_protocol::typed</code>, which the browser&#39;s compose path already used.</p>
<p>Per <code>doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)</code> the test could not survive as a comparison — type identity replaced its claim, and a hand-written comparison beside the shared function would be the copy coming back. It is re-scoped to what this crate still owes, which nothing else covers: the <strong>frame</strong> a typed string composes — basis, cell and command.</p>
<p>The classification&#39;s own obligation moved to <a href="https://github.com/fiddlerwoaroof/improvise/blob/main/tests/typed_string_agreement.rs"><code>tests/typed_string_agreement.rs</code></a>, where it is checked against the thing it could really disagree with: what <code>.improv</code> calls a number (gotcha 34).</p>
<h3 id="a-cell-projection-that-overtakes-its-labels-is-lost"><a class="anchor" href="#a-cell-projection-that-overtakes-its-labels-is-lost">#</a>a_cell_projection_that_overtakes_its_labels_is_lost</h3>
<p>Gotcha 33, carried across <em>this crate&#39;s</em> dispatch. The protocol crate pins the drop inside <code>GridCache::apply</code>; what is this crate&#39;s is that nothing between the socket and that cache buffers, sorts or retries — so the same early <code>Cells</code> is still lost after routing. The second half of the test feeds the same three updates in frame-first order and requires them to land, so &quot;everything is dropped&quot; cannot pass it.</p>
<h3 id="bootstrapping-from-a-compaction-lands-where-the-stream-lands"><a class="anchor" href="#bootstrapping-from-a-compaction-lands-where-the-stream-lands">#</a>bootstrapping_from_a_compaction_lands_where_the_stream_lands</h3>
<p><code>doctrine(fold_tests_must_test_order_not_the_axiom)</code>: a client that bootstraps from a compaction of the host&#39;s state must end up where a client that folded the whole stream is. The seed is the host&#39;s <em>current</em> state expressed as projections, not the head of the history.</p>
<h3 id="the-selection-is-placed-by-subtracting-the-scroll-offset"><a class="anchor" href="#the-selection-is-placed-by-subtracting-the-scroll-offset">#</a>the_selection_is_placed_by_subtracting_the_scroll_offset</h3>
<p>(<code>render.rs</code>.) The conversion that looks right until the first scroll: the grid is viewport-indexed, the cursor is not. The offsets in the fixture are <strong>nonzero and unequal</strong>, which is what separates &quot;reconciles&quot; from &quot;ignores the offset&quot; <em>and</em> from &quot;swaps the two offsets&quot;.</p>
<h3 id="column-groups-coalesce-per-header-as-the-tui-does"><a class="anchor" href="#column-groups-coalesce-per-header-as-the-tui-does">#</a>column_groups_coalesce_per_header_as_the_tui_does</h3>
<p>(<code>render.rs</code>.) Was <code>column_groups_coalesce_by_name_as_the_tui_does</code>, and its justification was consumed by the consolidation it predates. It read: the run boundary must be the name alone, &quot;because that is what <a href="https://github.com/fiddlerwoaroof/improvise/blob/main/src/ui/grid.rs"><code>src/ui/grid.rs</code></a> uses (<code>group_name != prev_group</code>) and the two ends must not disagree&quot;. Since <a class="tid" href="../tickets.html#improvise-8qlr" title="Column-group run coalescing is derived twice, and the agreement test does not pin the TUI arm">improvise-8qlr</a> <a href="https://github.com/fiddlerwoaroof/improvise/blob/main/src/ui/grid.rs"><code>src/ui/grid.rs</code></a> uses <code>AxisFrame::group_run_heads</code>, so there is no second copy to agree with — the rule can change once and both ends follow.</p>
<p><strong>Its fixture is what settled <a class="tid" href="../tickets.html#improvise-h0iv" title="group_runs coalesces by name alone, so two runs under different headers merge">improvise-h0iv</a>.</strong> It merges <code>Month/Q1</code> (expanded) with <code>Fiscal/Q1</code> (<strong>collapsed</strong>), and its own message said the merged span is &quot;drawn with the FIRST one&#39;s indicator&quot;. That is a collapsed group rendered as expanded, with nothing on screen saying otherwise. Whether two adjacent same-named runs are one span is a question of taste and is why h0iv was filed rather than fixed; a wrong collapse glyph is not. The expectation is now three runs, and the collapse flag of each is asserted — which is what makes this test see the fault at all, since the labels and spans alone cannot distinguish a merge that keeps the first indicator from a split.</p>
<h3 id="a-keyboard-edit-in-progress-is-part-of-the-render-model"><a class="anchor" href="#a-keyboard-edit-in-progress-is-part-of-the-render-model">#</a>a_keyboard_edit_in_progress_is_part_of_the_render_model</h3>
<p>(<code>render.rs</code>.) A keyboard edit has to be <em>visible</em>, and what is visible is decided in Rust rather than in the page. The failure this rules out is quiet: a client that resolved <code>i</code> locally would change its mode label, put the typing into <code>buffers[&quot;edit&quot;]</code>, and draw the cell&#39;s old value the whole time — so the user sees a keystroke do nothing, types over it, and commits something they never read. <code>None</code> outside an editing mode is the other half: a stale buffer must not draw an editor that is not open.</p>
<hr />
<h2 id="improvise-wasm-client-tests-browser-round-trip-rs"><a class="anchor" href="#improvise-wasm-client-tests-browser-round-trip-rs">#</a><code>improvise-wasm-client/tests/browser_round_trip.rs</code></h2>
<p>The file&#39;s scope is <a class="tid" href="../tickets.html#improvise-1ey" title="Browser frontend MVP: end-to-end working demo">improvise-1ey</a>&#39;s, quoted: <em>&quot;open an existing document server-side, view it in the browser, type a number into a cell, see the server value update and the projection arrive with the new value.&quot;</em> Everything in that sentence except the DOM is Rust on both sides, so the test drives the whole of it: a real <code>Host</code> over an authoritative <code>App</code>, and a real <code>Client</code> reached only through the string calls the page makes. The socket is the one thing left out, and it is covered by <code>improvise-ws-server</code>&#39;s own <code>tests/socket.rs</code>; what this adds is the half that crate cannot reach — that the <strong>browser client</strong> seeds, renders, composes and folds correctly against it.</p>
<p><strong>The discipline that makes it evidence rather than decoration</strong>: the client is touched <strong>only</strong> through <code>Client::viewport</code>, <code>Client::on_frame</code>, <code>Client::render_model</code> and <code>Client::typed_cell</code> — strings in and out — and it learns the cell to write from its own render model, never from the fixture. A <code>CellKey</code> anywhere in it would be the test knowing something a browser does not (ADR 002).</p>
<h3 id="a-grouped-row-axis-does-not-shift-the-write"><a class="anchor" href="#a-grouped-row-axis-does-not-shift-the-write">#</a>a_grouped_row_axis_does_not_shift_the_write</h3>
<p>A group header is a <strong>row of the render model and not a row of the grid</strong>, and a write composed against the wrong one lands on a different record — silently, because both are <code>usize</code>. <code>FirstEditable</code> therefore returns two numbers.</p>
<p>A fixture with no groups makes them coincide, which is exactly how an earlier version of the <code>first_editable</code> helper handed a <em>visual</em> index to <code>typed_cell</code> and passed (Copilot, PR #23). This test is the fixture the main round trip cannot be: a <code>Coastal</code> header sits <em>between</em> the two rows, so for the second row <code>visual_row</code> and <code>data_row</code> differ. Injecting the confusion — passing <code>at.visual_row</code> to <code>typed_cell</code> — leaves <code>East</code> unwritten. Note the ordering of the fixture is load-bearing: <code>West</code> comes first and ungrouped, because a header <em>before</em> the first row is dropped by <code>AxisFrame::body_from</code> and the two coordinates would coincide again.</p>
<h3 id="a-commit-keypress-is-one-mutation-among-view-commands-and-is-accepted"><a class="anchor" href="#a-commit-keypress-is-one-mutation-among-view-commands-and-is-accepted">#</a>a_commit_keypress_is_one_mutation_among_view_commands_and_is_accepted</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 one real producer of a multi-command frame must still be accepted.</strong> <code>CheckedBatch::against</code> answers a frame&#39;s basis before applying any of it, and every mutation answers for its own records. Getting that check right is the easy half; the expensive half is not tightening it until the browser stops working. A commit keypress is exactly the shape at risk — one <code>SetCell</code> plus the mode change, the scroll offsets and the re-seeded buffer around it — and it is produced by <code>keys.rs::commit</code>, which no test in the command crate can reach.</p>
<p>So it drives a <strong>real keypress</strong> through a <strong>real <code>Host</code></strong> and asserts the frame is accepted <em>and</em> lands: <code>ServerFrame::Rejected</code> anywhere in the answer, or an unchanged cell, is the regression. Asserting only &quot;no rejection&quot; would pass against a host that dropped the frame on the floor. The &quot;exactly one mutation&quot; assertion is a <strong>characterization of what <code>keys::commit</code> emits, not a protocol constraint</strong> — a frame may carry more than one — so that if <code>commit</code> ever started batching two, the test would be exercising something else and should be re-read rather than silently re-scoped. And the fixture types <code>888</code> <em>after</em> <code>i</code> has seeded the buffer, so the assertion is on the appended value: <code>888</code> alone would also be produced by a client that ignored the seed.</p>
<hr />
<h2 id="improvise-wasm-client-tests-lockstep-rs"><a class="anchor" href="#improvise-wasm-client-tests-lockstep-rs">#</a><code>improvise-wasm-client/tests/lockstep.rs</code></h2>
<p><code>src/keys.rs</code> decides a small set of keypresses without the document. The claim that makes that safe is not &quot;the rule looks the same&quot; but <strong>the two ends land in the same <code>ViewState</code></strong>, and that is checkable: the host&#39;s <code>reduce_key</code> runs the real keymap, registry and effect pipeline against an authoritative <code>App</code>, and <code>ViewState</code> is the protocol&#39;s own type on both sides. So every local outcome is driven through both and compared.</p>
<p>This is why the command crate is a dev-dependency. Dev-dependencies are not built for <code>--target wasm32-unknown-unknown</code>, so nothing the host owns reaches the browser bundle.</p>
<h3 id="a-locally-typed-character-lands-where-the-host-would-put-it"><a class="anchor" href="#a-locally-typed-character-lands-where-the-host-would-put-it">#</a>a_locally_typed_character_lands_where_the_host_would_put_it</h3>
<p>Driving one mode would say nothing about the others (<code>doctrine(one_arm_of_a_symmetric_rule_is_evidence_about_that_arm_only)</code>), so every text-entry mode is driven, and each with a <strong>non-empty</strong> buffer so an append that silently <em>replaced</em> the buffer would be visible.</p>
<h3 id="a-forwarded-key-reaches-the-host-unchanged"><a class="anchor" href="#a-forwarded-key-reaches-the-host-unchanged">#</a>a_forwarded_key_reaches_the_host_unchanged</h3>
<p>An unresolvable key must reach the host as a keypress, and what the host then does to <em>its</em> copy of this client&#39;s view slice is what the client will be told. Pinned as the shape the transport owes: the message carries the code and modifiers, and they survive the round trip. <code>Enter</code> rather than an arrow key, because since <a class="tid" href="../tickets.html#improvise-bpq2" title="Client-side key resolution: what must the projections carry?">improvise-bpq2</a> the arrows resolve locally and <code>enter-advance</code> is one of the movements deliberately left upstream — it aborts the rest of its batch at the bottom-right corner, which is a fact about the host&#39;s effect chain and not a cursor position.</p>
<h3 id="a-locally-resolved-cursor-move-lands-where-the-host-would-put-it"><a class="anchor" href="#a-locally-resolved-cursor-move-lands-where-the-host-would-put-it">#</a>a_locally_resolved_cursor_move_lands_where_the_host_would_put_it</h3>
<p>The headline claim for <a class="tid" href="../tickets.html#improvise-bpq2" title="Client-side key resolution: what must the projections carry?">improvise-bpq2</a>&#39;s first gap: a cursor key resolved on the client lands where the host would have put it — <strong>including the scroll that follows it</strong>, and including the clamp at the end of the grid.</p>
<p>The fixture is six rows in a three-row window, and the keys walk past the bottom of the window (so <code>row_offset</code> must move) and then past the bottom of the <em>grid</em> (so the clamp must hold). A client reading its bound off the row frame&#39;s length would stop at row 2 and never scroll; one that clamped against nothing would run off the end. Both are visible. Eight presses for six rows: the last three are the clamp.</p>
<p><strong>The fixture&#39;s one column is deliberate.</strong> The two ends measure their windows differently: the host&#39;s <code>visible_rows</code> is <code>term_height - 8</code> and its <code>visible_cols</code> is computed from column widths, while a browser announces both outright. <code>term_height</code> can be chosen to match the rows; nothing can be chosen to match the columns, so the fixture removes the question rather than guessing at an answer.</p>
<h3 id="a-locally-resolved-panel-cursor-agrees-with-the-host"><a class="anchor" href="#a-locally-resolved-panel-cursor-agrees-with-the-host">#</a>a_locally_resolved_panel_cursor_agrees_with_the_host</h3>
<p><a class="tid" href="../tickets.html#improvise-bpq2" title="Client-side key resolution: what must the projections carry?">improvise-bpq2</a>&#39;s second named gap, which turned out not to be one: the category panel&#39;s flattening depends on <code>expanded_cats</code>, but the derivation already lives in <code>improvise_protocol::viewmodel</code> and <em>both</em> ends call it (<a class="tid" href="../tickets.html#improvise-edp" title="Establish ViewModel layer: derived data between RenderCache and widgets">improvise-edp</a>). So this is not two derivations agreeing by luck — it is one derivation, and the test says so by <strong>expanding</strong> a category, which changes the flattened length the cursor clamps against. A client that counted categories would stop several rows early.</p>
<h3 id="the-buffer-i-seeds-is-the-value-the-host-would-seed"><a class="anchor" href="#the-buffer-i-seeds-is-the-value-the-host-would-seed">#</a>the_buffer_i_seeds_is_the_value_the_host_would_seed</h3>
<p><code>i</code> seeds the edit buffer from the cell&#39;s <strong>display value</strong>, and the two ends must agree on what that is: the host reads <code>App::display_value_at_cursor</code>, the client reads the text the server projected into its <code>GridCache</code>. Those are two paths to one string, and this is the assertion that they are the same string.</p>
<h3 id="entering-typing-and-cancelling-an-edit-agrees-with-the-host"><a class="anchor" href="#entering-typing-and-cancelling-an-edit-agrees-with-the-host">#</a>entering_typing_and_cancelling_an_edit_agrees_with_the_host</h3>
<p><code>Esc</code> rather than <code>Enter</code> for the cancel arm, because it is a <code>Binding::Sequence</code> and so exercises the all-or-nothing rule: half of it — a cleared buffer in a mode the user has not left — is worse than none.</p>
<h3 id="a-locally-committed-edit-leaves-both-ends-in-the-same-place"><a class="anchor" href="#a-locally-committed-edit-leaves-both-ends-in-the-same-place">#</a>a_locally_committed_edit_leaves_both_ends_in_the_same_place</h3>
<p>What is <em>not</em> compared is the document, and deliberately: the client answers with a <code>Command::Model</code>, which <code>reduce_view</code> drops, while <code>reduce_key</code> writes through the effect pipeline. The write&#39;s composition is asserted in <code>keys.rs</code> against <code>typed_cell</code>&#39;s own — the shared one — so what is left to prove here is the half a unit test cannot see, which is that the advance and the re-entry land in the same place.</p>
<h4 id="client-ready-to-edit-a-fixture-not-a-test"><a class="anchor" href="#client-ready-to-edit-a-fixture-not-a-test">#</a>client_ready_to_edit (a fixture, not a test)</h4>
<p>The published basis a real client folds before it is allowed to compose a write (ADR 001). The tiers are the ones lockstep never checks — the host applies the keypress directly rather than the client&#39;s <code>Command::Model</code> — but <code>BasisCache::claim</code> withholds until it has <em>something</em>, so a client without one resolves no commit at all.</p>
<hr />
<h2 id="harness-the-browser-specs"><a class="anchor" href="#harness-the-browser-specs">#</a><a href="https://github.com/fiddlerwoaroof/improvise/tree/main/harness"><code>harness/</code></a> — the browser specs</h2>
<p>These are Playwright tests, so the checker&#39;s <code>--check-evidence</code> pass (which scans Rust <code>fn</code> names) cannot see their names. They are recorded here under their exact <code>test(&quot;…&quot;)</code> titles anyway, because they are where the JavaScript evidence lives.</p>
<h4 id="harness-tests-shell-spec-js-the-file-s-own-scope"><a class="anchor" href="#harness-tests-shell-spec-js-the-file-s-own-scope">#</a>harness/tests/shell.spec.js — the file&#39;s own scope</h4>
<p>The three assertions <a class="tid" href="../tickets.html#improvise-hwpi" title="The browser shell fails quietly, and never reads a key">improvise-hwpi</a> could not write. That ticket fixed four things and said of itself that the fixes were &quot;as unverified as the bugs&quot;, because three of them are one-assertion tests that needed a browser. These are those three, plus the watchdog — which was found by reading, and only because someone went looking. Until this file, <a href="https://github.com/fiddlerwoaroof/improvise/blob/main/web/app.js"><code>web/app.js</code></a> and <a href="https://github.com/fiddlerwoaroof/improvise/blob/main/web/worker.js"><code>web/worker.js</code></a> had never been rendered or run.</p>
<p>Two fixtures are load-bearing. <code>NO_WEBSOCKET_HERE</code> is a peer that accepts the TCP connection and then refuses the WebSocket upgrade — the harness&#39;s own static file server — <strong>deterministic where a closed port is not</strong>: a refused port&#39;s <code>close</code> timing is a property of the host&#39;s network stack, and on the GitHub runner it did not arrive inside the window. The page cannot tell the two apart and neither can <a href="https://github.com/fiddlerwoaroof/improvise/blob/main/web/app.js"><code>web/app.js</code></a>: in both cases the socket never opened. <code>HANDSHAKE_TIMEOUT_MS</code> is <strong>imported from <a href="https://github.com/fiddlerwoaroof/improvise/blob/main/web/shell.js"><code>web/shell.js</code></a></strong> and restates nothing (PR #65), and <strong>one</strong> test outlasts it — this sentence said the opposite of both for a while, which is gotcha 48&#39;s third clause: a fact stated twice is changed by a grep, not an edit. Since <a class="tid" href="../tickets.html#improvise-669b" title="The harness spec and the page can still disagree about HANDSHAKE_TIMEOUT_MS through a stale assemble">improvise-669b</a> the spec also compares the <em>running page&#39;s</em> window against the one it imported, because the single home is in the TREE and the page loads <code>harness/serve/app/shell.js</code> — see <code>outlastTheWatchdog</code> below. <code>logStatus</code> records <strong>every distinct <code>#status</code></strong>, not just the last one, because several of these tests are about a message being <em>replaced</em>, which the final value cannot see.</p>
<h4 id="a-frame-the-client-cannot-fold-says-so-on-screen"><a class="anchor" href="#a-frame-the-client-cannot-fold-says-so-on-screen">#</a>&quot;a frame the client cannot fold says so on screen&quot;</h4>
<p><a class="tid" href="../tickets.html#improvise-hwpi" title="The browser shell fails quietly, and never reads a key">improvise-hwpi</a>: <code>on_frame</code> returns a <code>Result</code>, which wasm-bindgen throws. Thrown in a bare listener it reached the devtools console and nothing else — the grid simply stopped updating with nothing on screen saying why. <strong>The fault is injected at the <em>peer</em>, not in the page</strong> — a mocked socket that answers the handshake with a frame the client cannot fold — so this exercises the real <code>on_frame</code> and the real catch.</p>
<h4 id="a-peer-that-cannot-be-reached-is-named-and-not-called-a-disconnect"><a class="anchor" href="#a-peer-that-cannot-be-reached-is-named-and-not-called-a-disconnect">#</a>&quot;a peer that cannot be reached is named, and not called a disconnect&quot;</h4>
<p><a class="tid" href="../tickets.html#improvise-hwpi" title="The browser shell fails quietly, and never reads a key">improvise-hwpi</a>: without an <code>error</code> listener, a connection that never opened left <code>#status</code> blank, and &quot;the server is not running&quot; read exactly like &quot;still connecting&quot;. <strong>This test no longer says anything about the watchdog, and the paragraph below is why it never could.</strong> <code>shell.arm</code> runs only after <code>await transport.ready</code>, which resolves on <code>open</code> — so on a socket that never opens the watchdog is <strong>never armed at all</strong>, and denying its message here denies an unreachable event.</p>
<p><strong>That sentence was already in this file, five lines above an assertion that contradicted it.</strong> PR #65 added the watchdog clause <em>and edited this very paragraph</em>, inserting &quot;<code>runFor(10s)</code> advances the watchdog&quot; immediately before the sentence saying it is never armed. Reverting PR #21&#39;s routing, or stripping <code>fail()</code> of its <code>sawFrame()</code>, both left that clause <strong>passing</strong>. So a PR whose subject was &quot;a deleted guard went unnoticed&quot; planted the same shape one level up: three injections, three distinct test names, and two of them proving nothing. What the test guards now is the <strong>naming</strong> — <code>close</code> on a socket that never opened must not read as &quot;disconnected&quot; (<a class="tid" href="../tickets.html#improvise-taxx" title="[bug] The socket close listener overwrites a precise error with disconnected">improvise-taxx</a>) — which is the half injection 3 really does exercise.</p>
<p><strong>The mechanism, so it cannot come back.</strong> <code>outlastTheWatchdog</code> now asks the page <code>timesArmed() &gt; 0</code> before jumping, and a watchdog denial re-added to this path <strong>fails by name</strong>. It asks <em>ever armed</em> rather than <em>armed now</em>: by the time the disconnect test jumps, a correct page has already disarmed — that is the property — so <code>armed()</code> would have denied the passing case. Building that guard is what surfaced the distinction, which no amount of reading had.</p>
<h4 id="a-socket-that-opened-and-then-dropped-reads-as-disconnected-and-stays-that-way"><a class="anchor" href="#a-socket-that-opened-and-then-dropped-reads-as-disconnected-and-stays-that-way">#</a>&quot;a socket that opened and then dropped reads as disconnected, and stays that way&quot;</h4>
<p>The other arm, which must keep working when the first is fixed: a socket that really did open and then dropped IS a disconnect, and saying so is the whole reason <code>close</code> cannot simply be suppressed.</p>
<p><strong>It runs past <code>HANDSHAKE_TIMEOUT_MS</code> on purpose</strong> — through <code>page.clock</code>, not through a real wait. On this arm the watchdog IS armed — <code>armWatchdog</code> runs after <code>transport.ready</code> resolves, i.e. on <code>open</code> — so &quot;disconnected&quot; is written while a timer is pending, and the only reason it survives is that <code>close</code> goes through <code>setShellFailure</code>, which clears it. That was argued in a comment and asserted nowhere, and <strong>the argument was got wrong from the outside</strong>: a bug report (<a class="tid" href="../tickets.html#improvise-xps8" title="The handshake watchdog overwrites a genuine mid-session disconnect eight seconds later">improvise-xps8</a>) claimed the timer fired 8s later and overwrote &quot;disconnected&quot; with &quot;sent no document&quot;. It had been measured, honestly, but against a tree predating PR #21 — and the &quot;confirmation&quot; that it survived PR #21 simulated only that PR&#39;s <code>opened</code> flag and <strong>not its other half</strong>, routing BOTH close arms through <code>setShellFailure</code>. <em>A simulated fix that is not the fix you named reproduces a bug the real code does not have, and reads exactly like a confirmation.</em> The ticket was filed and closed <strong>invalid</strong>. Hence this assertion rather than another paragraph — proved discriminating by making <code>setShellFailure</code> skip its <code>clearTimeout</code>, which makes it fail. Same rule as <code>doctrine(an_injected_fault_must_be_the_fault_you_named)</code>, one level up.</p>
<p><strong>And it was lost a third time, by the PR that was arguing about exactly this.</strong> PR #61 split the browser tests into boundary contracts (Playwright) and unit tests (<code>node --test</code>), and merged these two tests into one that kept only the last-value assertions. <code>shell.test.js</code>&#39;s &quot;a loud failure DISARMS the watchdog&quot; covers <code>createShell</code> in isolation and <strong>cannot see whether <code>app.js</code> routes the close event through <code>fail()</code> rather than <code>setShell()</code></strong> — which is the half PR #21 fixed and the half that keeps getting lost. Reverting both socket arms to <code>setShell</code> left the node suite 26/26 and Playwright 13/13 green. Restored under their original names, and re-proved with that exact revert: the two-arm revert and the close-arm-only revert each fail &quot;…and stays that way&quot;, and dropping the <code>opened</code> flag fails &quot;…the watchdog stays out of it&quot;. <strong>Three injections, three different tests, none of them the unit test.</strong></p>
<p><strong>And the helper guards its own premise.</strong> <code>page.clock.runFor</code> on a page where <code>clock.install()</code> was never called does <strong>nothing and throws nothing</strong>, so a test that forgot the install would assert &quot;the watchdog never spoke&quot; against a watchdog never given the chance — vacuous, and green. <code>outlastTheWatchdog</code> reads <code>Date.now()</code> inside the page either side of the jump and requires it to have moved. Demonstrated both ways: with the install removed the suite passed 6/6, and with the guard in place the same omission fails by name.</p>
<p><strong>And it guards its second premise since <a class="tid" href="../tickets.html#improvise-669b" title="The harness spec and the page can still disagree about HANDSHAKE_TIMEOUT_MS through a stale assemble">improvise-669b</a>: that the page runs the constant the spec imported.</strong> PR #65 single-homed the handshake window in <a href="https://github.com/fiddlerwoaroof/improvise/blob/main/web/shell.js"><code>web/shell.js</code></a>, which closed the <em>understated spec constant</em> — but the home is single in the <strong>tree</strong>, not in what the page runs: the spec imports <code>../../web/shell.js</code> and the page loads <code>harness/serve/app/shell.js</code>, the copy <a href="https://github.com/fiddlerwoaroof/improvise/blob/main/web/assemble.sh"><code>web/assemble.sh</code></a> put there. A stale <code>serve/</code> restores the exact vacuity, and review demonstrated it — shrink the constant in source only, revert both socket arms, 6/6 green. <code>createShell</code> now exposes <code>timeoutMs()</code> beside <code>timesArmed()</code> and <code>outlastTheWatchdog</code> reads the running page&#39;s value and requires it to equal the import. Injected with the ticket&#39;s own recipe (<code>8000</code> → <code>100</code> in <a href="https://github.com/fiddlerwoaroof/improvise/blob/main/web/shell.js"><code>web/shell.js</code></a>, no rebuild): the run goes to 5 passed / 1 failed, on &quot;the page&#39;s handshake window is not the one this spec imported&quot;, where the same staleness was previously invisible.</p>
<p>Note what this does and does not reach: it guards the tests that <em>call the helper</em>. <code>&quot;a peer that accepts and then says nothing trips the watchdog&quot;</code> also names <code>HANDSHAKE_TIMEOUT_MS</code>, but as a Playwright <code>timeout:</code> margin rather than as a window to outlast — a stale value there costs patience, not evidence.</p>
<p><strong>The window is single-homed, and that is the second half of the same finding.</strong> <code>HANDSHAKE_TIMEOUT_MS</code> lived twice — once in <a href="https://github.com/fiddlerwoaroof/improvise/blob/main/web/app.js"><code>web/app.js</code></a>, once in the spec — with a comment saying a silent disagreement &quot;would pass for the wrong reason&quot; and nothing checking it. Setting the spec&#39;s copy to 1000 against the page&#39;s 8000 made the whole suite green <strong>against the reverted routing</strong>: the jump landed inside the window, the watchdog had not yet fired, and the denial held vacuously. It is now exported from <a href="https://github.com/fiddlerwoaroof/improvise/blob/main/web/shell.js"><code>web/shell.js</code></a> and imported by both, so the two cannot disagree; shrinking it in its one home still catches the routing revert, which is the demonstration that the guard did not go quiet.</p>
<p><strong>The faked clock is what makes the restoration cheap without weakening it</strong>, and the injections are the proof of that too: if <code>page.clock.runFor</code> did not really advance the page&#39;s timers, a broken routing could not surface the watchdog&#39;s message and those injections would have passed. They failed. The test keeps the real page, the real socket, the real listeners and the real <code>setTimeout</code> call — only the <em>passage of time</em> is simulated, and 9 seconds of wall clock became 75 milliseconds.</p>
<p><strong>The measurement that could not become a CI guard.</strong> Both of the above were once asserted as <code>test.fail</code> expected-failures and <strong>had to be removed</strong>: neither reproduced on the GitHub runner, where <code>close</code> does not follow <code>error</code> within the window. An expected-failure is green when the bug is present and red when it is absent, so one whose trigger is platform-dependent does not merely flake — <strong>it inverts CI&#39;s meaning</strong>, and it failed the build twice saying &quot;expected to fail, but passed&quot;. The lesson stands even though the second bug did not: <strong>assert the PROPERTY THAT MUST HOLD, which is portable, rather than the fault, whose trigger is the host&#39;s network stack.</strong></p>
<p>The first of the two, for the record, was real: the socket&#39;s <code>close</code> listener was unconditional, so on a connection that never opened it overwrote &quot;cannot reach …&quot; with &quot;disconnected&quot;. FIXED by PR #21. An <code>opened</code> flag is the only discriminator — <code>readyState</code> at close time cannot tell the two apart, and neither can the <code>CloseEvent</code>: both cases are code 1006, <code>wasClean: false</code>.</p>
<h4 id="a-peer-that-accepts-and-then-says-nothing-trips-the-watchdog"><a class="anchor" href="#a-peer-that-accepts-and-then-says-nothing-trips-the-watchdog">#</a>&quot;a peer that accepts and then says nothing trips the watchdog&quot;</h4>
<p>The first-frame watchdog, found by reading. The condition is one <strong>no per-transport check would catch</strong>: the peer accepted the connection and then said nothing. The mock accepts and never answers.</p>
<h4 id="the-page-draws-the-demo-document-out-of-the-worker"><a class="anchor" href="#the-page-draws-the-demo-document-out-of-the-worker">#</a>&quot;the page draws the demo document out of the worker&quot;</h4>
<p>No <code>?ws=</code>, so <a href="https://github.com/fiddlerwoaroof/improvise/blob/main/web/app.js"><code>web/app.js</code></a> spawns the in-page worker over OPFS: the whole application, with no server process. If this draws a grid, then <code>worker.js</code>, <code>bindings.rs</code>, <code>OpfsFs</code>, <code>vfs::open</code> and the seed fetch all ran.</p>
<h4 id="committing-an-edit-sends-one-frame-and-shows-the-host-s-value"><a class="anchor" href="#committing-an-edit-sends-one-frame-and-shows-the-host-s-value">#</a>&quot;committing an edit sends one frame and shows the host&#39;s value&quot;</h4>
<p>A commit round-trips through the host, and what lands on screen is the <strong>host&#39;s</strong> value rather than the string that was typed. <code>20000</code> is typed and <code>20,000</code> asserted: the number format is applied by the host and relayed back, so a page that echoed the input would fail.</p>
<p><strong>This is deliberately NOT named for <a class="tid" href="../tickets.html#improvise-hwpi" title="The browser shell fails quietly, and never reads a key">improvise-hwpi</a>&#39;s third fix</strong> (the <code>closed</code> re-entry guard in <code>edit</code>), which Copilot asked for on PR #24. That guard is <strong>not observable from the DOM</strong>, measured rather than assumed: with it removed, <code>close</code> still runs exactly twice, the frame is still sent exactly once, <code>#grid</code> still takes exactly two <code>childList</code> mutations, and the final text is identical. The only difference is one extra <em>entry</em> into <code>repaint()</code> that produces no output — because the second close is <code>close(false)</code>, so it does not re-send, and everything else it does is idempotent. That is precisely why hwpi called it &quot;harmless before, but only by accident&quot;. Counting <code>repaint()</code> entries would need a test hook inside <a href="https://github.com/fiddlerwoaroof/improvise/blob/main/web/app.js"><code>web/app.js</code></a>, which would be a page that knows it is being tested. So the test name says what is checked and this entry says what is not. What <em>is</em> observable is asserted: the editor is gone and did not come back, so no stray <code>&lt;input&gt;</code> is left behind and the host&#39;s value is not overwritten by the <code>original</code> text the second close writes before repainting.</p>
<h4 id="the-harness-can-see-a-failure"><a class="anchor" href="#the-harness-can-see-a-failure">#</a>&quot;the harness can see a failure&quot;</h4>
<p>Check the checker, and it runs <strong>first</strong>: a green run against a worker that never started is the failure this repo has a doctrine about. <code>run_canary</code> drives the <em>same</em> reporting path every real check uses, and the spec asserts it is reported as a failure with its message intact.</p>
<h4 id="every-check-in-the-battery-runs-and-passes"><a class="anchor" href="#every-check-in-the-battery-runs-and-passes">#</a>&quot;every check in the battery runs and passes&quot;</h4>
<p>The battery is asserted <strong>complete before any of it is believed</strong>: the report&#39;s names must equal <code>EXPECTED</code> exactly, so a build that dropped a check fails rather than passing with fewer.</p>
<h4 id="what-opfs-held-survives-a-reload-and-a-committed-delete-does-not"><a class="anchor" href="#what-opfs-held-survives-a-reload-and-a-committed-delete-does-not">#</a>&quot;what OPFS held survives a reload, and a committed delete does not&quot;</h4>
<p>The one thing no in-page check can establish. A delete is visible immediately and lands in OPFS at the end of the batch — so &quot;<code>commit_deletes</code> actually removed it&quot; is only answered by a fresh page. This is also the storage half of <a class="tid" href="../tickets.html#improvise-bck" title="Standalone web MVP: end-to-end offline demo">improvise-bck</a>&#39;s persistence criterion.</p>
<h4 id="the-opfs-contract-checks-harness-opfs-contract-src-lib-rs"><a class="anchor" href="#the-opfs-contract-checks-harness-opfs-contract-src-lib-rs">#</a>The opfs-contract checks (<a href="https://github.com/fiddlerwoaroof/improvise/blob/main/harness/opfs-contract/src/lib.rs"><code>harness/opfs-contract/src/lib.rs</code></a>)</h4>
<ul>
<li><strong><code>an_attached_but_unwritten_entry_is_not_a_document</code></strong> — <code>attach</code> must create the file to obtain a sync access handle for a document that does not exist yet, and that created file is zero bytes, which is NOT a document. <code>Storage::list</code> promises document names, so an attached-but-unwritten name must not be listed. Indexing it is exactly the <a class="tid" href="../tickets.html#improvise-9x6" title="Worker-hosted server bundle (wasm, MessageChannel transport)">improvise-9x6</a> browser failure: the seed was skipped as &quot;already there&quot; and <code>load_from</code> got an empty buffer. The check also opens a <strong>second instance</strong>, which shares no index, because the filter has to be in <code>refresh</code> and not only in the omission from <code>attach</code> — and then writes through the handle to show it is real and the name IS then a document.</li>
<li><strong><code>a_directory_entry_is_not_a_document</code></strong> — <code>keys()</code> yields DIRECTORIES as well as files, and <code>getFileHandle</code> on a directory rejects with <code>TypeMismatchError</code>. Treating that as &quot;could not measure, so assume a document&quot; would list an entry that can never be attached or read — the same picker bug the zero-byte filter removes (Copilot, PR #24). <code>OpfsFs</code> never creates a directory, so the harness makes one itself, and asserts a real file beside it IS listed — the precondition, so the check cannot pass by the directory never having been created, and evidence that <code>keys()</code> walked the root at all.</li>
<li><strong><code>refresh_walks_every_key_and_sorts_them</code></strong> — the hand-rolled <code>keys()</code> async-iterator walk in <code>refresh</code> is the least certain code in <code>opfs.rs</code> (<a class="tid" href="../tickets.html#improvise-xf1" title="Verify OpfsFs against Safari — Chromium and Firefox are covered">improvise-xf1</a> named it). One entry could pass by accident; several, out of order, cannot. It wipes first, like every other check: a battery whose steps depend on the leftovers of the step before breaks when one is inserted, which is exactly what inserting the directory check did.</li>
<li><strong><code>read_and_write_are_absolute_at_offset_zero</code></strong> — <code>FileSystemReadWriteOptions</code>&#39; <code>at</code> handling, the other thing xf1 called out. A <code>write</code> that appended instead of overwriting, or a <code>read</code> that started anywhere but 0, is invisible on a single short write and obvious across a long one followed by a short one.</li>
<li><strong><code>storage_contract_at_…</code></strong> — the point of the whole exercise. <code>attach</code> is hoisted out because acquiring a handle is the async half and <code>Storage::write</code> cannot await; <strong>the contract itself is the shared function, not a transcription of it</strong>.</li>
<li><strong><code>a_delete_is_visible_at_once_and_lands_on_commit</code></strong> — both halves, because either one alone would pass against a backend that got the timing exactly backwards.</li>
</ul>
<h3 id="a-locally-cycled-panel-focus-agrees-with-the-host"><a class="anchor" href="#a-locally-cycled-panel-focus-agrees-with-the-host">#</a>a_locally_cycled_panel_focus_agrees_with_the_host</h3>
<p><code>cycle-panel-focus</code> is bound to Tab in Normal only: <code>PanelKeymap</code>&#39;s spine binds Tab to <code>enter-mode normal</code> inside every panel (gotcha 12), so the <em>wrap</em> arms of <code>CyclePanelFocus</code> — the ones that start from the focused panel — are unreachable by keypress. The test therefore asserts the one reachable path and says so, rather than looping three times and passing on a mode that never moved. The first draft did loop, and failed on step 1 with the mode back at <code>Normal</code>; that failure is the evidence for this paragraph.</p>
<h3 id="a-locally-resolved-tile-axis-change-agrees-with-the-host"><a class="anchor" href="#a-locally-resolved-tile-axis-change-agrees-with-the-host">#</a>a_locally_resolved_tile_axis_change_agrees_with_the_host</h3>
<p>Both of <code>TileAxisOp</code>&#39;s names in one walk, because the command names itself off its <code>axis</code> field: <code>r</code> takes the <code>Some</code> arm (<code>SetAxis</code>) and <code>Enter</code> the <code>None</code> arm (<code>CycleAxis</code>), and a test that drove only one would leave a whole <code>Cmd</code> unmirrored. Injecting <code>parse_axis(&quot;row&quot;) =&gt; Axis::Column</code> fails it.</p>
<h3 id="the-category-panel-bindings-agree-with-the-host"><a class="anchor" href="#the-category-panel-bindings-agree-with-the-host">#</a>the_category_panel_bindings_agree_with_the_host</h3>
<p>The fixture sits on a <strong>header</strong> row for <code>Space</code>/<code>a</code> and on an <strong>item</strong> row for <code>Enter</code>/<code>d</code>, and then on a header again for a second <code>d</code>. Both of <code>FilterToItem</code>&#39;s arms and both of <code>DeleteCategoryAtCursor</code>&#39;s arms are therefore reached. That is not decoration: the first version sat only on the item row, and injecting a wrong sentence into the <em>category</em> arm of <code>delete_at_cat_cursor</code> did <strong>not</strong> fail it. One fixture position, one arm — and a check that passes against an arm it never enters is the defect this repo keeps finding.</p>
<h3 id="the-view-panel-bindings-agree-with-the-host"><a class="anchor" href="#the-view-panel-bindings-agree-with-the-host">#</a>the_view_panel_bindings_agree_with_the_host</h3>
<p>Two views in the fixture, and the cursor moved to 1 before <code>Enter</code>, because <code>view_at_cursor</code> reading <code>.get(0)</code> is the natural wrong implementation and a single-view fixture cannot tell it from the right one. Injecting <code>.get(0)</code> fails it.</p>
<p>It presses <code>d</code> as well, and that was added after the fact: the first version said &quot;create, switch and delete&quot; in its doc comment and pressed only <code>n</code> and <code>Enter</code>, so <code>delete_view_at_cursor</code> — whose <em>only</em> decision is the cursor step-back it makes away from the top of the list — was never run. The adversarial review of PR #50 deleted that step-back and the workspace stayed at 1322 passing. <code>d</code> is pressed at index 1 so the decision is reached, and <code>both_ends</code> now compares every cursor (see below), without which the deletion would still be invisible.</p>
<h3 id="a-locally-committed-minibuffer-agrees-with-the-host"><a class="anchor" href="#a-locally-committed-minibuffer-agrees-with-the-host">#</a>a_locally_committed_minibuffer_agrees_with_the_host</h3>
<p><code>commit-buffer</code> for <strong>all three</strong> kinds, driven as keystrokes so the <code>TextEntry</code> spine&#39;s <code>clear-buffer</code> weld is part of what is compared. <code>CommitKind::Item</code> carries its category, resolved from <code>AppMode::ItemAdd</code> — so the mode the test starts in is load-bearing, not scene-setting. Injecting <code>AddCategory</code> in place of <code>AddItem</code> fails it on the document comparison.</p>
<p>Formula was missing from the first version, and it is the kind that matters most: it is the <strong>only</strong> one whose <code>on_commit()</code> is <code>Some</code>, so a test of the other two cannot see the mode a commit returns to. The review of PR #50 broke both <code>CommitKind::status</code>&#39;s and <code>on_commit</code>&#39;s Formula arms and nothing failed. Its text is <strong>seeded rather than typed</strong>, and valid, for the reason the next entry gives.</p>
<h3 id="a-formula-the-client-cannot-parse-diverges-on-the-status-line"><a class="anchor" href="#a-formula-the-client-cannot-parse-diverges-on-the-status-line">#</a>a_formula_the_client_cannot_parse_diverges_on_the_status_line</h3>
<p>The one thing a client cannot mirror about <code>commit-buffer formula</code>, found by widening the test above and kept as a <strong>pinned divergence</strong> rather than a fix (<a class="tid" href="../tickets.html#improvise-cl7y" title="A browser reports &quot;Formula added&quot; for a formula only the host can parse">improvise-cl7y</a>).</p>
<p><code>CommitBuffer</code> emits its optimistic status first and the add effect last, so on the host a parse failure overwrites &quot;Formula added&quot; with &quot;Formula error: …&quot; (<a class="tid" href="../tickets.html#improvise-6t3" title="Unify Commit{Formula,CategoryAdd,ItemAdd} into CommitBuffer(kind)">improvise-6t3</a>). The client reproduces the command line for line — which is right — but it cannot parse, so it folds &quot;Formula added&quot; and stops. The user sees a browser confidently report a formula it did not add.</p>
<p>This asserts <strong>both</strong> sides, so a fix has to come here and change it. That is the difference between a pinned divergence and a comment: <code>a_locally_committed _minibuffer_agrees_with_the_host</code> would otherwise have to use a valid formula forever with no record of why.</p>
<h3 id="panel-focus-cycles-from-the-focused-panel-not-the-first"><a class="anchor" href="#panel-focus-cycles-from-the-focused-panel-not-the-first">#</a>panel_focus_cycles_from_the_focused_panel_not_the_first</h3>
<p><code>CyclePanelFocus</code>&#39;s wrap arms, driven directly rather than through a key. <code>PanelKeymap</code>&#39;s spine binds Tab to <code>enter-mode normal</code> inside every panel (gotcha 12), so <strong>no keypress reaches them</strong> — which is why the lockstep test asserts only the reachable path and why breaking the <code>start</code> index survived the review&#39;s injection pass. Mirroring a <code>Cmd</code> faithfully includes the arms the keymap cannot reach today; testing them takes a direct call.</p>
<p>The fixture leaves the <strong>category</strong> panel closed, so &quot;the next open one&quot; and &quot;the next one&quot; give different answers and a mirror that walked the array blindly would fail.</p>
<h3 id="a-locally-resolved-clear-and-paste-agree-with-the-host"><a class="anchor" href="#a-locally-resolved-clear-and-paste-agree-with-the-host">#</a>a_locally_resolved_clear_and_paste_agree_with_the_host</h3>
<p>The two writes ADR 002 lets a thin client name. Note the fixture&#39;s cell key has <strong>three</strong> coordinates: <code>Workbook::add_category</code> puts a new category on <code>Axis::Column</code>, so <code>Product</code> is part of the cell&#39;s address and a two-coordinate key names a cell the grid never shows. The first version asserted on the two-coordinate key, and passed the per-key comparisons while asserting against a cell nothing had touched.</p>
<h3 id="walking-off-the-end-of-the-help-pages-agrees-with-the-host"><a class="anchor" href="#walking-off-the-end-of-the-help-pages-agrees-with-the-host">#</a>walking_off_the_end_of_the_help_pages_agrees_with_the_host</h3>
<p>The bug this was written to demonstrate: <code>effect::HelpPageNext</code> clamps and <code>HelpPagePrev</code> saturates, while <code>reduce_view</code>&#39;s <code>Command::HelpPage</code> arm <em>wrapped</em>. Two derivations of one fact, in disagreement, invisible because <code>h</code>/<code>l</code> in the help overlay were upstream keys and no client had ever sent the command. It failed on the first press past the end — client 0, host 4 — and the wire was changed to match the effect. Whether both should wrap instead is <a class="tid" href="../tickets.html#improvise-trd3" title="Help pages clamp at both ends; decide whether they should wrap">improvise-trd3</a>.</p>
<h4 id="opens-help-and-its-rows-carry-the-keys-and-the-words-not-empty-frames"><a class="anchor" href="#opens-help-and-its-rows-carry-the-keys-and-the-words-not-empty-frames">#</a>&quot;? opens help, and its rows carry the keys and the words, not empty frames&quot;</h4>
<p>This entry was demoted to prose as Retired when PR #61 deleted the spec. PR #72 restored the coverage under a new name, so the claim is live again and the heading now names the test that exists. What follows is the original evidence; <a class="tid" href="../tickets.html#improvise-igxp" title="The browser help overlay&#39;s rows are asserted as present, never as content">improvise-igxp</a>&#39;s stronger assertions are recorded separately below.</p>
<p><a class="tid" href="../tickets.html#improvise-ktv1" title="Move the help content into the shared layer so a browser can show help">improvise-ktv1</a>&#39;s acceptance in one place: <strong>a browser shows help.</strong> <code>?</code> had entered <code>AppMode::Help</code> since the keymap was shared, and nothing appeared — the mode changed and the page had no overlay, which is worse than a key that does nothing because the user thinks they missed it.</p>
<p>It asserts the shape, not merely that something is visible: the active tab is the shared module&#39;s page title (<code>Welcome</code>, then <code>Navigation</code> after <code>l</code>, which also exercises <code>Command::HelpPage</code> folding on the client), and the body has a key row. Two faults were injected and each failed it. Dropping the <code>paintHelp(model)</code> call fails <code>toBeVisible</code>; removing the <code>mode != Help</code> guard in <code>help_rows</code> — so the overlay is never empty and therefore never hidden — fails <code>toBeHidden</code>. The second is the one worth having: an overlay that is always open passes any check that only ever opens it.</p>
<p>It runs against the <strong>assembled</strong> site like every other test here, so it covers <code>render.rs</code>, the serialized model, <a href="https://github.com/fiddlerwoaroof/improvise/blob/main/web/app.js"><code>web/app.js</code></a> and the CSS together.</p>
<p><strong>It was gone for a release, and that is why the entry reads oddly.</strong> <code>36ade35</code> deleted it with the rest of <code>harness/tests/widgets.spec.js</code> and nothing replaced it, so the heading promised coverage that did not exist -- exactly the cost <a class="tid" href="../tickets.html#improvise-5ray" title="check-evidence ignores #### headings and every browser-test entry uses them">improvise-5ray</a> names, and invisible because the evidence checker then read only <code>###</code> headings and only Rust names. PR #69 taught it to see both; PR #72 restored the coverage under the current name, with the stronger content assertions <a class="tid" href="../tickets.html#improvise-igxp" title="The browser help overlay&#39;s rows are asserted as present, never as content">improvise-igxp</a> asked for.</p>
<p>The injections above are the original two and still describe the property. The present test is <a href="https://github.com/fiddlerwoaroof/improvise/blob/main/harness/tests/help.spec.js"><code>harness/tests/help.spec.js</code></a>, and its own entry below records what igxp added: the rows are read as <code>dt</code>/<code>dd</code> pairs and asserted as text, so blanking <code>keys.textContent</code> fails it where <code>toBeVisible</code> passed.</p>
<h3 id="the-shipped-demo-seeds-a-worker-whose-raw-cells-are-settable"><a class="anchor" href="#the-shipped-demo-seeds-a-worker-whose-raw-cells-are-settable">#</a>the_shipped_demo_seeds_a_worker_whose_raw_cells_are_settable</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> renamed this from <code>..._whose_every_cell_is_settable</code> and narrowed what it claims. The old loop called <code>MainThread::cell</code> — which panics on anything but a stored cell — over <em>every</em> column including <code>Profit</code>, which <a href="https://github.com/fiddlerwoaroof/improvise/blob/main/web/demo.improv"><code>web/demo.improv</code></a> defines as <code>Revenue - Cost</code>. It passed only because a formula target projected <code>CellId::Record</code>, which is the bug the ticket names: a test whose name was false for a third of the grid it walked.</p>
<p>It now walks the two raw columns and asserts separately that the <code>Profit</code> column is <code>CellId::Derived</code>, so it fails if either half moves. That second assertion is what makes the narrowing honest rather than a retreat — dropping the column would have left the page free to offer it again with nothing noticing.</p>
<h3 id="the-page-keys-turn-the-odometer-as-the-command-does"><a class="anchor" href="#the-page-keys-turn-the-odometer-as-the-command-does">#</a>the_page_keys_turn_the_odometer_as_the_command_does</h3>
<p><a class="tid" href="../tickets.html#improvise-p1ym" title="Project the page-axis item lists so [ and ] work in a browser">improvise-p1ym</a>. <code>GridCache::page_coords</code> carried <code>(category, selected item)</code> and nothing else, so <code>[</code>/<code>]</code> resolved to a command name the client held but could not compose an argument for. They reached <code>Host::handle</code>, which answers an empty vec — <code>doctrine(an_upstream_key_is_a_black_hole)</code> — so an independent smoke test found them inert against the live page, where <code>Quarter</code> sits on the page axis precisely because paging is a thing a visitor tries.</p>
<p>The test asserts the <em>shape</em> composed, not that something resolved: two page axes, the least significant at Q4, so one press must both wrap <code>Quarter</code> and carry into <code>Year</code>. A resolver that moved only the first axis, or carried the wrong way, fails it.</p>
<h3 id="a-filter-axis-is-projected-but-not-cycled"><a class="anchor" href="#a-filter-axis-is-projected-but-not-cycled">#</a>a_filter_axis_is_projected_but_not_cycled</h3>
<p>The gotcha-27 arm. Page and Filter both constrain which records are visible, and <code>GridLayout::page_coords</code> is deliberately <code>filtering_cats()</code> (Page ∪ Filter) because the <em>layout</em> does not care which the user can navigate. The projection copied that verbatim, so a client handed the list would have tried to cycle a drill&#39;s fixed coordinate.</p>
<p><code>PageAxis::items</code> is <code>Some</code> only for <code>Axis::Page</code>, so the difference is in the type rather than in a rule each client remembers. The test adds a Filter entry to a client that is otherwise paging normally and asserts the two Page axes still move while the Filter one does not — an assertion that only the Filter axis is skipped, not that paging stopped.</p>
<h3 id="a-locally-resolved-page-turn-lands-where-the-host-would-put-it"><a class="anchor" href="#a-locally-resolved-page-turn-lands-where-the-host-would-put-it">#</a>a_locally_resolved_page_turn_lands_where_the_host_would_put_it</h3>
<p>The lockstep claim, and <strong>the first version of it was vacuous</strong>, which is worth recording because it looked exactly right. It rebuilt the client&#39;s cache from the host after every press and then compared the client&#39;s cache against that same host&#39;s document — two readings of one value. Reversing the odometer&#39;s significance in <code>page_cycle</code> left it green.</p>
<p>It now drives two independent documents: one <code>Host</code> fed the commands the client composed, one <code>App</code> fed the same keypress through <code>reduce_key</code>, compared on <code>View::page_selection</code> — the document, never a projection. Proved discriminating by that same injection (<code>for i in (0..axes.len()).rev()</code> → <code>for i in 0..axes.len()</code>), which now reports Q2 against Q3. The <code>assert_ne!</code> against a fresh fixture is the second half: without it, a resolver that emitted nothing at all would pass by moving neither.</p>
<h4 id="harness-tests-widgets-spec-js-the-file-s-own-scope"><a class="anchor" href="#harness-tests-widgets-spec-js-the-file-s-own-scope">#</a>harness/tests/widgets.spec.js — the file&#39;s own scope</h4>
<p><strong>Both files were deleted by PR #61</strong>, which moved the pointer&#39;s and the widgets&#39; browser halves into <a href="https://github.com/fiddlerwoaroof/improvise/blob/main/harness/tests/contract.spec.js"><code>harness/tests/contract.spec.js</code></a> and the rest into <code>node --test web/tests</code>. What follows is the history of how these assertions were arrived at; the assertions themselves now live there.</p>
<p><strong>The file is gone; its subject is not.</strong> All four widgets it named were one failure at one boundary — a <code>RenderModel</code> field with no element painting it — and that is now asserted once, from both sides, by <a href="https://github.com/fiddlerwoaroof/improvise/blob/main/web/tests/render_surface.test.js"><code>web/tests/render_surface.test.js</code></a> and <a href="https://github.com/fiddlerwoaroof/improvise/blob/main/harness/tests/contract.spec.js"><code>harness/tests/contract.spec.js</code></a>. What is <em>not</em> carried over is each key&#39;s meaning, which <code>improvise_protocol::resolve</code> decides and tests. Everything below is the history of the bug, and still true.</p>
<p>A user reported three things as broken: &quot;I can&#39;t expand categories, and the Tile + View UIs don&#39;t work.&quot; None of the three was a regression. Driving the assembled site under Chromium separated them, and the separation is the point: two of the three had <em>every</em> layer but the last one, and the layer they were missing was not the same in each case.</p>
<p><strong>Category expand already worked, by keyboard.</strong> <code>Shift-C</code>, <code>j</code>, <code>Enter</code> folds <code>Command::ToggleCatExpand</code> client-side and the items appear. What does not work is the <em>mouse</em>: <code>categoryList</code> builds plain <code>&lt;li&gt;</code>s with no listener, so a click on the triangle does nothing at all. That half is <a class="tid" href="../tickets.html#improvise-7ybz" title="The browser category panel has no pointer affordance at all">improvise-7ybz</a>, and it is a product decision about the whole page&#39;s pointer model rather than a defect in an implemented feature — <code>#grid</code> offers a click only on an editable cell, so there is no click-to-select either.</p>
<p><strong>The tile bar and the view panel had the cache, the keymap, the resolver and the host round trip, and no DOM.</strong> Before the fix, <code>render_state()</code> in the browser carried five <code>TileBarCache</code> entries and both <code>ViewListCache</code> views; <code>Shift-T</code> entered <code>TileSelect</code> and <code>ArrowRight</code> moved <code>tile_cat_idx</code>; <code>r</code>/<code>c</code>/<code>p</code> reached the host and really did move a category between axes. <code>RenderModel</code> simply had no field for either, and <a href="https://github.com/fiddlerwoaroof/improvise/blob/main/web/index.html"><code>web/index.html</code></a> had no element. So the keys worked and the user was aiming them at nothing — which reads exactly like &quot;the Tile UI doesn&#39;t work&quot;. The formula panel was the third of <code>Panel::ALL</code> in the same state and is fixed by the same mechanism.</p>
<p><strong>The fault injection was the unfixed bundle itself.</strong> Run against the tree at <code>a3bef6e</code>, the category test passes and the other four fail — <code>#tiles</code>, <code>#views</code> and <code>#formulas</code> resolve to zero elements. That is the evidence the four tests are discriminating, and it is why the category test stays: it is the control that says the harness really is driving the keyboard.</p>
<p>Two assertions in the first draft were wrong, and both corrections are worth keeping. <code>Axis::short_label()</code> is <strong>three columns</strong>, so a <code>Page</code> tile reads <code>Quarter Pag</code>, not <code>Quarter Page</code> — a test asserting the long spelling would have forced the page to invent a label instead of echoing <code>Axis</code>&#39;s own. And switching views leaves the panel <strong>open</strong>: <code>switch-view-at-cursor</code> returns to Normal and never closes the panel, exactly as <a href="https://github.com/fiddlerwoaroof/improvise/blob/main/src/draw.rs"><code>src/draw.rs</code></a> keeps drawing it off <code>view_panel_open</code> alone. Asserting it hidden would have pinned the browser to behaviour the TUI does not have.</p>
<p>The gating rule itself — an empty list is what says a panel is closed, the same rule <code>help</code> follows — is asserted in Rust as well, by <code>a_closed_panel_projects_no_rows_and_an_open_one_does</code> and <code>the_tile_bar_is_always_present_and_only_its_cursor_is_modal</code>, because a browser test cannot distinguish &quot;closed&quot; from &quot;the cache is empty&quot; and those two tests fill the caches first.</p>
<p>What these tests do <strong>not</strong> cover: the pointer (<a class="tid" href="../tickets.html#improvise-7ybz" title="The browser category panel has no pointer affordance at all">improvise-7ybz</a>), and the inconsistency that <code>#views</code>/<code>#formulas</code> honour their open flags while <code>#categories</code> is drawn unconditionally because <code>app.js</code> has never read <code>category_panel_open</code> (<a class="tid" href="../tickets.html#improvise-wsaw" title="The browser ignores category_panel_open, so C toggles nothing visible">improvise-wsaw</a>).</p>
<p>One assertion was added after the first green run and is worth naming: the tile test originally asserted only that the <em>tile label</em> changed to <code>Product Col</code>. That is the bar reporting itself, which is exactly the shape of test this repo keeps catching — it would pass against a tile bar that displayed an axis the document had not taken. It now also asserts the grid reshaped: <code>Espresso</code> moves from a <code>tbody</code> row header to a <code>thead</code> column header. <code>.first()</code> is needed there because Product then shares the column axis with <code>_Measure</code>, so the label appears once per header level — which is itself a small confirmation that gotcha 44&#39;s multi-level frame reaches the browser.</p>
<p><strong>Three review findings, applied.</strong> The second is the one worth carrying.</p>
<p><strong>The page had two spellings of the same triangle, inside the file that declares it has one.</strong> <code>indicator()</code> carries the comment <em>&quot;only this spelling is the page&#39;s (gotcha 26)&quot;</em> — singular — and <code>categoryList</code> did not call it: it inlined U+25B8/U+25BE (the SMALL triangles) where <code>indicator()</code>, the page&#39;s own grid group headers and <a href="https://github.com/fiddlerwoaroof/improvise/blob/main/src/ui/category_panel.rs"><code>src/ui/category_panel.rs</code></a> all draw U+25B6/U+25BC. So the browser&#39;s category panel disagreed with the TUI&#39;s <em>and</em> with its own grid. <code>categoryList</code> now calls <code>indicator(!row.expanded)</code>.</p>
<p><strong>And the first version of this spec pinned the wrong spelling.</strong> It asserted <code>/▸ Product/</code> and <code>▾</code> — the divergent pair — so the obvious cure, which the file&#39;s own comment implies should already be in place, would have <em>failed a test this work added</em>. That is this repo&#39;s characteristic defect exactly: not a wrong line, a test that cements a state the doctrine says is wrong, and it makes the cure more expensive the longer it sits. The spec now asserts <code>▶</code>/<code>▼</code>, and the change was proved discriminating by running it against the pre-fix bundle, where it fails with <code>Received string: &quot;▸ Product (4) R&quot;</code>.</p>
<p>Note what this does <strong>not</strong> settle: whether glyphs should move to the protocol the way <code>short_label()</code> did. That is <strong><a class="tid" href="../tickets.html#improvise-h04z" title="Decide whether UI glyphs move to the protocol, as short_label() did">improvise-h04z</a></strong>, not answered here — what is fixed is the page contradicting itself. <code>viewRow</code> still spells <code>&quot;▶ &quot;</code> as a literal duplicating <code>src/ui/view_panel.rs:62</code>; the two agree today, which is precisely the state <code>categoryList</code> was in before it drifted.</p>
<p><strong>And the drift was already spreading, which is the evidence that ticket needs.</strong> <code>harness/tests/pointer.spec.js</code>, written on a different branch, had <em>independently</em> grown the same <code>▸</code>/<code>▾</code> assertions — so the corrected <code>indicator()</code> broke it too. Three files converging on a spelling nothing compares is the failure design-principles §5 predicts, observed rather than argued, and it is what answers &quot;is one duplicated literal worth a protocol change&quot; with data instead of doctrine.</p>
<p><strong><code>el.tiles.hidden</code> was a no-op</strong>, removed with its comment. <code>#tiles</code> declares <code>display: flex</code>, an author-origin rule that beats the UA sheet&#39;s <code>[hidden] { display: none }</code> at any specificity, so the guard never hid anything and &quot;an empty tile list draws nothing at all&quot; was false. <code>#views</code>, <code>#formulas</code> and <code>#help</code> declare no <code>display</code> and so are genuinely hidden by the attribute. Worth knowing before anyone &quot;fixes&quot; it: because <code>#tiles</code> is always laid out, its height never changes, so it feeds nothing back into the <code>ResizeObserver</code> — restoring the guard would introduce exactly the first-paint height change the observer argument assumes away.</p>
<p><strong>The <code>ResizeObserver</code> loop is no longer &quot;argued, not tested&quot;, and the proof is worth more than the measurement.</strong> It was measured: 820 combinations — four panel states (none / views / formulas / tile-select) × heights 360-440 in 2px steps × five widths straddling the <code>COL_PX = 110</code> boundary, each counting <code>#grid</code> child replacements in a 500ms quiet window. Every size settled; nothing repainted twice. But the reason is structural, so the measurement is a check on it rather than the evidence itself: <strong>the <code>frameCost</code> dependency graph is acyclic.</strong> <code>frameCost.cols</code> is <code>row_levels</code>, the number of row-axis header <em>levels</em>, which does not depend on how many rows are projected. <code>frameCost.rows</code> derives from <code>col_levels.length</code> and <code>col_groups</code>, which depend on the projected <em>column</em> window, which depends on width and <code>frameCost.cols</code>. So <code>cols → rows</code>, and nothing depends on <code>rows</code>. There is no cycle to oscillate. A change that made either term depend on the projected row count would create one, and that — not the pixel arithmetic — is the thing to check.</p>
<p><strong>A harness port collision can make a run test somebody else&#39;s tree, silently.</strong> <code>playwright.config.js</code> hard-coded 8099 with <code>reuseExistingServer: true</code>, so a <code>python3 -m http.server</code> left by another worktree is <em>adopted</em>, and the run then exercises that checkout with no warning. The port is now <code>IMPROVISE_HARNESS_PORT</code>, defaulting to 8099 so CI is unchanged. The tell is a result that disagrees with the file you just read: check <code>lsof -a -p &lt;pid&gt; -d cwd</code> on the listener before believing it.</p>
<p><strong>It fires in both directions, and the dangerous one is the false PASS.</strong> Two agents hit it in one session. One got a false <em>negative</em> — a glyph fix reported absent, because the adopted tree did not have it — which is merely confusing, since a failure sends you looking. The other got <code>widgets.spec.js</code> reporting <strong>5 passed against a tree it had content-verified as having no <code>#tiles</code> and no <code>paintTiles</code> at all</strong>, which is impossible and was caught only because that agent knew what the tree contained and refused to believe the number. <strong>A false pass prompts nobody.</strong> That is what makes a hard-coded port with <code>reuseExistingServer</code> a correctness bug rather than an inconvenience, and it is the same rule as the rest of this file: a green result is evidence only once you know what it measured.</p>
<h3 id="a-click-focuses-the-panel-moves-its-cursor-and-activates-the-row"><a class="anchor" href="#a-click-focuses-the-panel-moves-its-cursor-and-activates-the-row">#</a>a_click_focuses_the_panel_moves_its_cursor_and_activates_the_row</h3>
<p><a class="tid" href="../tickets.html#improvise-7ybz" title="The browser category panel has no pointer affordance at all">improvise-7ybz</a>&#39;s whole difficulty in one assertion, and the reason the click resolvers take an <strong>index</strong> rather than reading the cursor.</p>
<p>A click means three things at once — focus the panel, move its cursor to the row under the pointer, activate that row — and the third is resolved in the <em>same pure call</em> as the first two, before any of them has been folded. So <code>filter_to_item_at(state, index)</code> must be asked about <code>index</code>; asking <code>filter_to_item(state)</code> reads <code>view.cat_panel_cursor</code>, which is still wherever the keyboard last left it. That is why <code>cat_tree_entry</code>, <code>toggle_cat_expand</code>, <code>filter_to_item</code> and <code>tile_axis</code> each gained an <code>_at</code> twin and the cursor-reading form became a one-line call of it: one definition, two entry points.</p>
<p><strong>Injected fault</strong>, run: <code>commands.extend(filter_to_item_at(state, index)…)</code> → <code>filter_to_item(state)</code>. The test fails with <code>ToggleCatExpand(&quot;Region&quot;)</code> against <code>ToggleCatExpand(&quot;Product&quot;)</code> — the panel cursor lands on the row clicked while the <em>expansion</em> happens to the row that was selected before, which is the silent half. The fixture therefore has two categories and clicks the <strong>second</strong>: with one category, or with a click on the row already under the cursor, the bug is invisible.</p>
<p>The sibling <code>a_click_in_an_already_focused_panel_only_moves_and_activates</code> pins the other half: <code>focus</code> emits only what is not already true, so a click inside the focused panel is not a mode change on every click. Without it, &quot;always emit <code>SetPanelOpen</code> + <code>ChangeMode</code>&quot; passes the first test.</p>
<h3 id="a-cell-click-is-absolute-even-though-the-page-paints-a-window"><a class="anchor" href="#a-cell-click-is-absolute-even-though-the-page-paints-a-window">#</a>a_cell_click_is_absolute_even_though_the_page_paints_a_window</h3>
<p>The pointer&#39;s version of <code>the_selection_is_placed_by_subtracting_the_scroll_offset</code>, and it fails the same way: <code>GridCache</code> is viewport-indexed, <code>ViewState::selected</code> is absolute, so a click at painted <code>(2,1)</code> under offsets <code>(12,3)</code> is <code>(14,4)</code>. Offsets are <strong>nonzero and unequal</strong> for the same reason as its sibling — equal offsets cannot separate &quot;reconciles&quot; from &quot;swaps the two&quot;. The second half asserts a click past the grid&#39;s extent resolves to nothing, which is what stops a click on the padding below the last row selecting a cell that does not exist.</p>
<h3 id="a-click-past-the-last-row-resolves-to-nothing"><a class="anchor" href="#a-click-past-the-last-row-resolves-to-nothing">#</a>a_click_past_the_last_row_resolves_to_nothing</h3>
<p><code>doctrine(check_the_checker)</code> for the two above: the empty-answer path. Without it, a <code>click</code> that returned <code>Vec::new()</code> unconditionally would pass every &quot;resolves to nothing&quot; expectation while failing silently everywhere else — so this one is only meaningful <em>because</em> its neighbours assert non-empty answers.</p>
<h4 id="harness-tests-pointer-spec-js-the-file-s-own-scope"><a class="anchor" href="#harness-tests-pointer-spec-js-the-file-s-own-scope">#</a>harness/tests/pointer.spec.js — the file&#39;s own scope</h4>
<p><strong>Both files were deleted by PR #61</strong>, which moved the pointer&#39;s and the widgets&#39; browser halves into <a href="https://github.com/fiddlerwoaroof/improvise/blob/main/harness/tests/contract.spec.js"><code>harness/tests/contract.spec.js</code></a> and the rest into <code>node --test web/tests</code>. What follows is the history of how these assertions were arrived at; the assertions themselves now live there.</p>
<p><strong>The file is gone; one test carries it.</strong> <code>contract.spec.js</code>&#39;s &quot;each clickable surface is wired to its own index&quot; keeps what only a browser can say — the handler is on the element, it passes <em>that</em> element&#39;s index, and the answer is painted — against three surfaces. What each click <em>means</em> is <code>improvise_protocol::resolve</code>&#39;s, and its tests are named below. The six tests&#39; injection stands as recorded.</p>
<p>The user&#39;s ruling on <a class="tid" href="../tickets.html#improvise-7ybz" title="The browser category panel has no pointer affordance at all">improvise-7ybz</a> (&quot;build it — one entry point per widget&quot;) and <a class="tid" href="../tickets.html#improvise-wsaw" title="The browser ignores category_panel_open, so C toggles nothing visible">improvise-wsaw</a> (&quot;honour the flag&quot;), executed in a real browser.</p>
<p>Six tests, and <strong>all six fail against the bundle built immediately before them</strong> — the tree that already had the three widgets drawn (PR #55) but no click handlers and no <code>category_panel_open</code> read. That is the injection: the sidebar is visible when it should be hidden, and every <code>click()</code> resolves to a <code>&lt;li&gt;</code> that has no listener, so nothing moves. The category test is the sharpest, because the element it clicks <em>exists and looks interactive</em> in the unfixed tree — which is exactly what the user hit.</p>
<p>Two properties here that the Rust tests structurally cannot reach. <strong>The pointer and the keyboard are one cursor</strong>: <code>the keyboard continues from where the pointer left the cursor</code> clicks a cell, closes the editor, presses ArrowLeft, and requires the move to start from the clicked cell — if the click had only opened an editor (the pre-fix behaviour) the arrow would move from the origin instead. And <strong>the tile click is asserted against the grid</strong>, not only against its own label: <code>Espresso</code> must appear as a <code>thead</code> header, because a tile bar reporting an axis the document did not take is the failure shape this repo keeps finding.</p>
<p>One fixture note worth keeping: <code>openPanelThenLeave</code> opens the panel with <code>Shift-C</code> and then presses <code>Escape</code>, which returns to Normal <strong>without closing the panel</strong> (<code>PanelKeymap</code>&#39;s Esc is <code>enter-mode normal</code>, nothing more). That is the only state in which the &quot;focus&quot; third of the click is observable — from a freshly opened panel the mode is already <code>CategoryPanel</code> and the assertion would pass without the focus commands existing at all.</p>
<h4 id="harness-tests-drill-spec-js-the-file-s-own-scope"><a class="anchor" href="#harness-tests-drill-spec-js-the-file-s-own-scope">#</a>harness/tests/drill.spec.js — the file&#39;s own scope</h4>
<p>The fourth widget the user reported, and the one with a <strong>different answer from the other three</strong>. The tile bar, view panel and formula panel were each one <code>RenderModel</code> field short — cheap. Drill is not: <strong>there is no wire vocabulary for it</strong>, which is <a class="tid" href="../tickets.html#improvise-gd28" title="Drill over the wire needs the addressed command form">improvise-gd28</a>, and these tests document the refusal rather than pretending the gap is a bug to be fixed here.</p>
<p><strong>Where it stops, by layer.</strong> Everything below the command exists and was verified in the browser, not read: <code>project::grid_of</code> emits <code>CellId::Aggregate</code>, the client receives it (<code>{&quot;Aggregate&quot;:&quot;750ab1f5ffa96d5e…&quot;}</code> in <code>render_state</code>), <code>render.rs::editable</code> correctly answers false for it, and <code>resolve.rs</code> reaches the aggregate arm. What is missing is one link: <code>reduce.rs</code> contains <strong>no <code>Drill</code> variant at all</strong> — not <code>Command</code>, not <code>ModelCommand</code>. The seam is ready and the sentence is not sayable.</p>
<p><strong>Why it was left out is a decision, not an oversight</strong> (gotcha 38): drilling creates the <code>_Drill</code> view and writes <code>drill_state</code>, which gotchas 30 and 1 rule <strong>session-local</strong> — <code>pending_edits</code> are keyed by <code>(row, RecordCol)</code> and belong to whoever staged them. A <code>Command::Model</code> is routed with <strong>no <code>SessionId</code></strong>, which is right exactly when a change is addressed to <em>no one</em> session; drill is addressed to precisely one. That is the same argument that removed <code>ApplyAndClearDrill</code> from the wire in <a class="tid" href="../tickets.html#improvise-3x3u" title="Resolve the two unaddressed nullary wire variants before concurrency lands">improvise-3x3u</a>, so adding <code>ModelCommand::Drill</code> would reintroduce the bug that removal fixed. It needs the <strong>addressed</strong> form — ADR 001 layer 2.</p>
<p><strong>The two entry points used to fail differently, and that asymmetry was the finding.</strong> <code>i</code>/<code>a</code> on an aggregate declines <strong>out loud</strong>: <code>edit_or_drill</code> answers the <code>CellId::Aggregate</code> arm with a status the user can read. But <code>&gt;</code> (<code>drill-into-cell</code>) was a name <code>resolve::step</code> did not handle, so it became <code>KeyOutcome::Upstream</code> → <code>ClientFrame::Key</code> → <code>Host::handle</code>, whose <code>Key</code> arm is an explicit empty vec — <strong>a black hole, not a slow path</strong> (gotcha 47). Pressing <code>&gt;</code> in the browser did nothing whatsoever: no status, no mode change, nothing. That is why this read as &quot;never confirmed working&quot; rather than as a refusal.</p>
<p><strong>That silence is now closed, and the closing is what this file ships.</strong> <code>step</code> answers <code>&quot;drill-into-cell&quot;</code> with a <code>SetStatus</code> saying drill is not available in the browser yet — an interim the wording of which is load-bearing, since it must be true both today and on the day <a class="tid" href="../tickets.html#improvise-gd28" title="Drill over the wire needs the addressed command form">improvise-gd28</a> lands. The wire vocabulary is still absent; only the <em>silence</em> was fixed. Note for gd28: when the real drill arrives, a leftover <code>&quot;drill-into-cell&quot;</code> arm in <code>step</code> would <strong>shadow</strong> it with nothing failing, because a name that resolves never reaches the host.</p>
<p><strong>The TUI is the control and it works</strong>: 12 drill tests pass across <code>cmd/grid.rs</code>, <code>cmd/mode.rs</code>, <code>ui/effect.rs</code> and <code>ui/app.rs</code>. So this is isolated to the client/wire, not a general regression.</p>
<p><strong>A fixture fact that is itself part of the answer.</strong> <a href="https://github.com/fiddlerwoaroof/improvise/blob/main/web/demo.improv"><code>web/demo.improv</code></a> has <strong>no aggregate cell at all</strong> — gotcha 46 puts exactly one category on each visible axis precisely so every cell is a <code>Record</code>, and <a href="https://github.com/fiddlerwoaroof/improvise/blob/main/tests/web_demo_model.rs"><code>tests/web_demo_model.rs</code></a> asserts it. So on the shipped demo a user cannot even reach the refusal without first changing an axis. <code>anAggregateGrid</code> therefore <em>makes</em> one, through the tile bar, the way a user would: <code>Quarter</code> cycles Page → None and every cell becomes an aggregate. The precondition is then <strong>asserted</strong> (<code>td.editable</code> count 0) rather than assumed, so neither test can pass against a grid of Records.</p>
<p><strong>Both injections, run</strong> — against the tree <em>before</em> the interim, which is what made them two faults rather than one. Replacing the aggregate arm with <code>None</code> — silencing the honest refusal — failed <strong>both</strong> tests, and the second failed on its <em><code>check_the_checker</code> control</em>, which is the point: with nothing answering, the control can no longer tell &quot;<code>&gt;</code> is silent&quot; from &quot;the page is dead&quot;, and the test correctly refuses to pass. Adding <code>&quot;drill-into-cell&quot; =&gt; Some(vec![SetStatus(…)])</code> to <code>step</code> — making <code>&gt;</code> resolve — failed <strong>only</strong> the second test and left the first green. Two faults, two different failure signatures, neither test passing for the other&#39;s reason. That second injection is now the shipped code, which is why the test it broke was rewritten rather than kept.</p>
<p>That control is load-bearing and not decoration: a test asserting <em>nothing happened</em> passes against a page that has stopped listening, so it presses <code>i</code> afterwards and requires <code>#status</code> to change. Without that, this file would be folklore with a green tick.</p>
<h3 id="the-drill-key-and-an-aggregate-edit-refuse-for-different-reasons"><a class="anchor" href="#the-drill-key-and-an-aggregate-edit-refuse-for-different-reasons">#</a>the_drill_key_and_an_aggregate_edit_refuse_for_different_reasons</h3>
<p>The Rust half of the interim, and the assertion that carries the whole weight. Two keys on the same aggregate cell must both refuse and must <strong>not</strong> refuse with the same sentence: <code>&gt;</code> says drill is unavailable, <code>i</code> says drilling into an aggregate needs the host. Collapsing them into one message would be the tempting tidy-up and would lose the distinction between &quot;this key does nothing here yet&quot; and &quot;this cell is not one you may write&quot;.</p>
<p><strong>The injection, and why it is not the obvious one.</strong> Replacing <code>&gt;</code>&#39;s sentence with the <em>aggregate</em> sentence looks like it should fail on the first assertion — and does not: that sentence contains both &quot;drill&quot; and &quot;browser&quot;, so it clears the substring check. What fires is the anti-aggregate assertion lower down. The claim that the second assertion is the load-bearing one was doubted on review, tested, and confirmed; record it here because the first assertion reads like the discriminating one and is not.</p>
<p>The harness sibling (<code>drill.spec.js</code>) asserts the same distinction in a browser, but its <code>not.toContainText(&quot;aggregate&quot;)</code> is <strong>decoration under the only injection that exists</strong> — Playwright throws at the positive assertion above it first, so that line is never reached. The Rust assertion is the guard; the harness one is end-to-end confirmation that the string survives the wire.</p>
<h3 id="a-view-row-click-focuses-moves-and-switches-to-the-row-clicked"><a class="anchor" href="#a-view-row-click-focuses-moves-and-switches-to-the-row-clicked">#</a>a_view_row_click_focuses_moves_and_switches_to_the_row_clicked</h3>
<p><a class="tid" href="../tickets.html#improvise-iioi" title="The view and formula panels have no pointer affordance while the category panel does">improvise-iioi</a>, and it is the <strong>same trap</strong> as the category click one level over: the activation must be resolved against the row <strong>clicked</strong>, never <code>view_panel_cursor</code>, because the <code>SetPanelCursor</code> ahead of it has not been folded when the resolver runs. Injecting exactly that — reading the name at the cursor instead of at <code>index</code> — yields <code>SwitchView(&quot;Default&quot;)</code> beside <code>SetPanelCursor { cursor: 1 }</code>, i.e. the cursor lands on <code>Plan</code> and the document switches to <code>Default</code>. Silent, and wrong in the half a user does not watch.</p>
<p>The fixture therefore holds <strong>two</strong> views and clicks the <strong>second</strong>. With one view, or with a click on the row already under the cursor, the bug is invisible — the same reason <code>a_click_focuses_the_panel_moves_its_cursor_and_activates_the_row</code> clicks Product rather than <code>_Index</code>.</p>
<h3 id="a-formula-row-click-navigates-and-activates-nothing"><a class="anchor" href="#a-formula-row-click-navigates-and-activates-nothing">#</a>a_formula_row_click_navigates_and_activates_nothing</h3>
<p>The negative arm, and it is a statement about the <em>rule</em> rather than about the formula panel. The rule is &quot;go there, then do what <code>Enter</code> does there&quot;; <code>ModeKey::FormulaPanel</code>&#39;s keymap binds <code>a</code>/<code>n</code>/<code>o</code> to <code>enter-mode formula-edit</code> and <code>d</code>/<code>Delete</code> to delete, and <strong>no <code>Enter</code> at all</strong>. So the rule yields navigation and nothing else, and that is the rule applying rather than the fix stopping half way.</p>
<p>This test asserts the <em>exact</em> command vector, which is the whole of what it can say. It once carried a second, explanatory <code>assert!</code> that no <code>FormulaEdit</code> mode change appears — <strong>which could never have been the failing assertion</strong>, because the <code>assert_eq!</code> above it pins the same three commands and aborts first. A reviewer demonstrated it by injecting a <code>ChangeMode(FormulaEdit)</code> and watching the panic land on the <code>assert_eq!</code> with the explanatory message never printed. It was a comment wearing an <code>assert!</code>, and the argument now lives in <a href="../code/clients.html"><code>context/code/clients.md</code></a> where a comment run cannot hold it.</p>
<h3 id="the-formula-panel-has-no-enter-to-mirror-and-the-view-panel-does"><a class="anchor" href="#the-formula-panel-has-no-enter-to-mirror-and-the-view-panel-does">#</a>the_formula_panel_has_no_enter_to_mirror_and_the_view_panel_does</h3>
<p>The guard that the removed assertion was pretending to be. The asymmetry — a view-row click activates, a formula-row click does not — is a fact about the <strong>keymap</strong>, and nothing tied the click to it. The reviewer proved the gap by adding <code>Enter -&gt; enter-mode formula-edit</code> to <code>ModeKey::FormulaPanel</code> and running the whole workspace: <strong>1381 passing, nothing failed</strong>, while <code>click_formula_row</code> still activated nothing. The click and the keyboard then disagree about what a formula row does, silently.</p>
<p>Injecting that same binding now fails this test and only this test. The failure message names <code>click_formula_row</code> as what must change, because the person who adds an <code>Enter</code> binding is not the person reading <code>resolve.rs</code>&#39;s click arms. The positive half (<code>ViewPanel</code> <em>does</em> bind <code>Enter</code>) is there so the test cannot pass by asserting that no panel binds anything.</p>
<h4 id="the-view-and-formula-row-clicks-in-the-browser-improvise-iioi"><a class="anchor" href="#the-view-and-formula-row-clicks-in-the-browser-improvise-iioi">#</a>The view and formula row clicks in the browser (<a class="tid" href="../tickets.html#improvise-iioi" title="The view and formula panels have no pointer affordance while the category panel does">improvise-iioi</a>)</h4>
<p>These began as two tests in <code>harness/tests/pointer.spec.js</code>. <strong>That file no longer exists</strong>: PR #61 deleted it and moved the pointer&#39;s browser half into <a href="https://github.com/fiddlerwoaroof/improvise/blob/main/harness/tests/contract.spec.js"><code>harness/tests/contract.spec.js</code></a>, whose stated scope is the wiring — the handler is on the element and passes <em>that</em> element&#39;s index — while what a click MEANS is <code>improvise_protocol::resolve</code>&#39;s and tested there. The assertions were merged into that file&#39;s own click test rather than restored, because resurrecting a file a merge deleted is how a deliberate removal gets undone by reflex.</p>
<p>They leave the panel <strong>open but unfocused</strong> — the only state in which the <em>focus</em> third of the click is observable.</p>
<p><strong>History, not current evidence:</strong> the original pair was proved against the bundle built immediately before the fix, both failing there while the pre-existing pointer assertions passed. That run was against <code>harness/tests/pointer.spec.js</code>, which no longer exists. What holds the relocated assertions honest is the injection below, run in their new home.</p>
<p>The view test asserts three things — <code>#view-name</code>, the <code>active</code> marker and the <code>selected</code> cursor — because switching ends in <code>NORMAL</code> and a test that only checked the mode would pass against a click that did nothing but leave the panel. <strong>The relocation initially dropped two of the three</strong>, and a reviewer caught it; they are restored.</p>
<p><strong>And the fixture was vacuous in a way the prose had already warned about.</strong> <code>Default</code> is index 0 and holds <code>view_panel_cursor</code> by default, so clicking it — which both the old <code>pointer.spec.js</code> and the first relocated version did — makes <code>index</code> and the cursor the same number, and reading either gives the same name. A reviewer injected <code>switch_view_at(state, state.view.view_panel_cursor)</code> into <code>click_view_row</code> and <strong>the whole contract suite passed 3/3</strong>: the exact fault this test, the ticket and the Rust test all exist for was invisible to the browser half. The fixture now switches to <code>Default</code> by <strong>keyboard</strong>, leaving the cursor there, then <strong>clicks <code>Plan</code></strong> — so the two readings name different rows and the outcomes differ. Re-running that injection now fails it.</p>
<p>The blindness was inherited rather than introduced: the deleted file clicked <code>Default</code> too. It became worth fixing because the relocated test is now the <em>only</em> browser assertion on that surface.</p>
<p><strong>One fact the reviewer&#39;s report supplied and this fix leans on</strong>: shipping the cure for one panel and not its two siblings <em>reproduces the original report</em>. A user who has just learned category rows are clickable clicks a view row and reads the silence as broken — which is literally what &quot;I can&#39;t expand categories&quot; meant.</p>
<h4 id="the-harness-port-reuse-is-now-opt-in"><a class="anchor" href="#the-harness-port-reuse-is-now-opt-in">#</a>The harness port: reuse is now opt-in</h4>
<p><code>reuseExistingServer: !process.env.CI</code> became <code>=== &quot;1&quot;</code> on <code>IMPROVISE_HARNESS_REUSE</code>. A default-on reuse adopts a server another checkout left on the port and then tests <strong>that tree</strong>, which fired in both directions in one session — a false negative and, worse, a false <em>positive</em>. A path-hash default was considered and rejected: it still silently reuses when two agents share a worktree path, where an explicit opt-in cannot. CI is unaffected (it never reused) and a developer wanting the old speed sets the variable.</p>
<h3 id="a-click-that-cannot-compose-its-activation-composes-nothing"><a class="anchor" href="#a-click-that-cannot-compose-its-activation-composes-nothing">#</a>a_click_that_cannot_compose_its_activation_composes_nothing</h3>
<p><a class="tid" href="../tickets.html#improvise-hkr8" title="A click that cannot compose its activation keeps the prefix and drops the payload">improvise-hkr8</a>. <code>click_category_row</code> ended with <code>filter_to_item_at(state, index).unwrap_or_default()</code>, and that <code>None</code> arm is not &quot;nothing to do&quot; — it is <code>BasisCache::claim</code> answering that the server has published no basis yet (gotcha 43), so the document change cannot be composed. The click therefore kept its <strong>navigation prefix</strong> (focus the panel, move the cursor), dropped its activation, and reported success. The keyboard handles the identical <code>None</code> correctly, by mapping <code>step(..) == None</code> to <code>KeyOutcome::Upstream</code>.</p>
<p>The fixture has to do two things a careless one would not. It <strong>expands <code>Region</code></strong> so that row 1 is an <code>Item</code> row — a header row activates via <code>ToggleCatExpand</code>, which needs no basis and so cannot show the bug — and it asserts both preconditions (<code>cat_tree_entry_at</code> is an <code>Item</code>, <code>claim</code> is <code>None</code>) rather than assuming them, because an empty <code>ClientState</code> makes every state-dependent refusal look like the one under test.</p>
<p>Injecting the old <code>unwrap_or_default()</code> fails this test and only this test.</p>
<h3 id="the-same-click-composes-everything-once-a-basis-is-published"><a class="anchor" href="#the-same-click-composes-everything-once-a-basis-is-published">#</a>the_same_click_composes_everything_once_a_basis_is_published</h3>
<p>The positive arm of the one above, and it is what stops the fix from being &quot;return nothing more often&quot;. With a basis published, the same click composes all six commands: open, focus, move the cursor, two <code>Command::Model</code>s and the status. Without this, deleting the activation entirely would pass hkr8&#39;s test.</p>
<h3 id="a-first-tile-click-selects-without-touching-the-document"><a class="anchor" href="#a-first-tile-click-selects-without-touching-the-document">#</a>a_first_tile_click_selects_without_touching_the_document</h3>
<p>The user&#39;s ruling: a click selects, a second click cycles. The fixture publishes a basis <strong>deliberately</strong>, so the absence of a <code>Command::Model</code> is a decision rather than a client that could not compose one — pointed at an unpublished basis this test would pass for hkr8&#39;s reason instead of its own.</p>
<p>Both halves are asserted, because &quot;the axis did not change&quot; passes against a page where nothing happened at all: the positive half requires <code>SetTileCatIdx</code> plus <code>ChangeMode(TileSelect)</code>, the negative half requires no <code>Command::Model</code>.</p>
<p><strong>Four injections, four distinct signatures.</strong> &quot;First click also cycles&quot; fails this test, <code>the_first_click_on_tile_zero_selects_it_like_any_other</code> and <code>clicking_a_different_tile_selects_it_rather_than_cycling_it</code> — and <strong>not</strong> <code>a_second_click_on_the_selected_tile_cycles_its_axis</code>. &quot;Second click does not cycle&quot; fails only that last one. &quot;<code>already_selected</code> reads the index alone&quot; fails only the tile-zero test. Reverting hkr8 fails only the hkr8 test. No injection passes for another&#39;s reason.</p>
<h3 id="t-then-a-click-on-the-leftmost-tile-cycles-it-immediately"><a class="anchor" href="#t-then-a-click-on-the-leftmost-tile-cycles-it-immediately">#</a>t_then_a_click_on_the_leftmost_tile_cycles_it_immediately</h3>
<p>Found on review, and it is a defect in the <em>wording</em> rather than the code. <code>T</code> enters <code>TileSelect</code> leaving <code>tile_cat_idx</code> at <strong>0</strong>, so a click on the leftmost tile is a click on the already-selected one and cycles on the <strong>first</strong> gesture. Both loose summaries of the ruling — &quot;a click never mutates on its own&quot; and &quot;a <strong>second</strong> click cycles&quot; — are false against that probe.</p>
<p>The behaviour is right: a click does what <code>Enter</code> does there, and <code>Enter</code> in <code>TileSelect</code> cycles the tile under the cursor. The resolver cannot see how the state arose and must not try to. So the test exists to <strong>name</strong> the case rather than to change it, and it asserts the neighbouring tile answers the identical gesture by selecting — because the surprising half is that two adjacent tiles differ, and a reader who meets that in the wild should find it written down.</p>
<p>Note what this means for its sibling <code>a_second_click_on_the_selected_tile_cycles_its_axis</code>: that test sets the state directly rather than by clicking, so it <strong>already was</strong> the keyboard-entry case. Nothing was untested; the name and the argument were missing.</p>
<h3 id="enter-in-an-empty-view-panel-resolves-locally-rather-than-upstream"><a class="anchor" href="#enter-in-an-empty-view-panel-resolves-locally-rather-than-upstream">#</a>enter_in_an_empty_view_panel_resolves_locally_rather_than_upstream</h3>
<p><code>switch_view_at</code> returns <code>Option</code>, and its two callers answer the <code>None</code> differently on purpose. <strong>From a <code>click_*</code>, <code>None</code> means &quot;this gesture composed nothing&quot;</strong> — a dynamic fact, and hkr8&#39;s rule. <strong>From <code>step</code>, <code>None</code> means &quot;this client cannot execute this command <em>name</em>&quot;</strong> — a <em>static</em> fact about the vocabulary, which <code>the_command_names_that_still_need_the_host_are_these</code> exists to keep static. Two questions sharing one type, which is why the asymmetry reads wrong at first. (<code>delete_view_at_cursor</code> already answered <code>Some(Vec::new())</code> the same way, so this follows the file rather than inventing.)</p>
<p>Nothing held the keyboard side to it until this test. A reviewer removed the <code>unwrap_or_default()</code> so out-of-range propagates <code>None</code> — making <code>Enter</code> in an empty view panel <code>KeyOutcome::Upstream</code>, gotcha 47&#39;s black hole — and <strong>the whole workspace passed under <code>--no-fail-fast</code></strong>. That would make membership of the host-needed list state-dependent, the one property that list denies. Injecting it now fails this test and only this test.</p>
<p>The assertion is <code>Ignored</code>, not <code>Local(vec![])</code>: <code>KeyOutcome::of</code> maps an empty vec to <code>Ignored</code> by construction, so <code>Local</code> is never empty. What matters is that it is not <code>Upstream</code>.</p>
<h3 id="the-first-click-on-tile-zero-selects-it-like-any-other"><a class="anchor" href="#the-first-click-on-tile-zero-selects-it-like-any-other">#</a>the_first_click_on_tile_zero_selects_it_like_any_other</h3>
<p>The trap in the ruling&#39;s own wording. &quot;A second click on the already-selected tile&quot; reads as an index comparison, and <code>tile_cat_idx</code> is <strong>0 before anything has selected anything</strong> — so an index-only test would read tile 0 as already selected and cycle it on its very first click, making the non-mutation guarantee true of every tile but one. The condition is <code>mode == TileSelect</code> <strong>and</strong> <code>tile_cat_idx == index</code>.</p>
<h3 id="clicking-a-different-tile-selects-it-rather-than-cycling-it"><a class="anchor" href="#clicking-a-different-tile-selects-it-rather-than-cycling-it">#</a>clicking_a_different_tile_selects_it_rather_than_cycling_it</h3>
<p>The other half of the same trap, from the opposite side: without it, &quot;a click does not mutate&quot; would hold only for the first click of a session, since every click after the first is a &quot;second click&quot; in the loose reading. Clicking A then B must select B.</p>
<h4 id="how-to-know-a-launch-default-has-stopped-disarming-things"><a class="anchor" href="#how-to-know-a-launch-default-has-stopped-disarming-things">#</a>How to know a launch default has stopped disarming things</h4>
<p>The question this left open was <strong>how to know you have exhausted the class</strong>. Grep does not answer it: grep finds sites that <em>name</em> the flag, and the hazard is a test that merely <strong>starts from a default-built state</strong> and asserts something downstream, mentioning nothing. Three disarmed tests were found by grepping <code>category_panel_open</code>; that was luck, not method.</p>
<p><strong>Mutation answers it, because it does not care what a test mentions.</strong> The recipe, which a reviewer ran across five sites in about twenty minutes:</p>
<blockquote><p>For every production site that <strong>writes, propagates or reads</strong> the value, replace it with the constant the default supplies, and run <code>--no-fail-fast</code>. Sites where nothing fails are suspects. Then <strong>re-run each suspect with the default inverted.</strong> If it now fails something, the test was <strong>vacuous</strong> — the default disarmed it, and the fix is the fixture. If it still fails nothing, it was simply <strong>unguarded</strong> — a different problem, pre-existing, and a bead rather than a fixture.</p></blockquote>
<p><strong>The second step is the one that matters</strong>, and without it the two classes are indistinguishable: both look like &quot;nothing failed&quot;. It is what established that the class is closed at <strong>two</strong> (<code>set_panel_open_and_cursor</code>, <code>panel_commands_address_the_named_panel_only</code>) and that two further silent sites are older problems this ruling did not cause:</p>
<ul>
<li><code>ui/app.rs</code>&#39;s <code>App::cmd_context</code> propagator — hardcode it <code>true</code> and the whole workspace passes. <code>registry.rs</code> reads it for <code>toggle-panel-and-focus</code>&#39;s open-vs-close decision, so <code>C</code> on a closed panel would decide to <em>close</em> and nothing would fail. Invisible because <code>cmd/mod.rs</code>&#39;s test helper sets the field explicitly, so no command test reaches the propagator. Filed.</li>
<li><code>improvise-wasm-client/src/render.rs</code>&#39;s <code>RenderModel</code> field — hardcode it <code>true</code> and <strong>Rust sees nothing at all</strong>; only the Chromium harness catches it, at <code>contract.spec.js</code>&#39;s <code>toBeHidden</code>. That is literally <a class="tid" href="../tickets.html#improvise-wsaw" title="The browser ignores category_panel_open, so C toggles nothing visible">improvise-wsaw</a> reintroduced one layer up, and <strong>a Rust-only CI run cannot see it</strong> — which is the sharpest argument on file for why the browser job must be able to fail the build.</li>
</ul>
<p><strong>And a repair needs its own probe, because a repair can be shaped like a fix.</strong> The two-step recipe above finds a <em>disarmed</em> test; it does not tell you whether the repair you then write is real. That gap is not hypothetical — the first repair of <code>panel_commands_address_the_named_panel_only</code> folded <code>[open:false, open:true]</code>, read as a fix in review, and was still vacuous.</p>
<p>The probe that settles it is a <strong>one-directional</strong> fault: make the writer <code>if open { set true }</code>, so the flag can be raised but never lowered. <strong>A vacuous repair cannot see this</strong>, because a test that only ever asserts the <code>true</code> direction is satisfied by a writer that only ever writes <code>true</code>. Injected into both copies at once, it fails <code>set_panel_open_and_cursor</code> <strong>and</strong> <code>panel_commands_address_the_named_panel_only</code>, each in its own copy — which is what establishes both repairs are genuine rather than merely differently spelled. Prefer it to re-reading the diff: the diff contained <code>open: false</code> in the version that was vacuous.</p>
<p>Note <code>resolve.rs</code>&#39;s <code>panel_open</code> reader is <strong>well</strong> guarded by contrast: hardcoding it <code>true</code> fails two click tests. So the picture is not &quot;everything touching this flag is untested&quot;; it is specifically the propagators.</p>
<h3 id="the-terminal-constructor-inherits-the-shared-default-rather-than-setting-it"><a class="anchor" href="#the-terminal-constructor-inherits-the-shared-default-rather-than-setting-it">#</a>the_terminal_constructor_inherits_the_shared_default_rather_than_setting_it</h3>
<p><strong>Prospective rather than present-tense, and the doc comment says so.</strong> With three panels, <code>the_terminal_app_launches_with_the_category_panel_open</code> and <code>the_other_two_panels_are_shut_at_launch</code> already cover every <code>Panel::ALL</code> member, so this test cannot currently fail without one of them failing too. A reviewer confirmed that by control.</p>
<p>It earns its place the way gotcha 12 describes <code>Panel::ALL</code>: a <strong>fourth</strong> panel reaches it from one <code>ALL</code> entry, and at that moment it becomes the only test asserting that <code>App::new</code> did not quietly give the TUI its own launch state — the divergence <a class="tid" href="../tickets.html#improvise-wsaw" title="The browser ignores category_panel_open, so C toggles nothing visible">improvise-wsaw</a> closed. Recorded here so nobody later reads a green tick as evidence of something it is not yet checking.</p>
<h3 id="the-terminal-app-launches-with-the-category-panel-open"><a class="anchor" href="#the-terminal-app-launches-with-the-category-panel-open">#</a>the_terminal_app_launches_with_the_category_panel_open</h3>
<p>The user ruled (2026-09-20) that the category sidebar is visible at launch in the <strong>TUI as well as</strong> the browser, so <code>improvise model.improv</code> opening with it showing is intended. This is the guard on the front end the ruling newly changes: <code>a_fresh_session_opens_with_the_category_panel</code> asserts <code>ViewState::default()</code> in the <em>protocol</em> crate, and nothing downstream of it.</p>
<p>Why not a rendered-frame test: design-principles §7 says not to test ratatui <code>Widget::render</code>, and there is no <code>TestBackend</code> anywhere in this repo. The honest target is the TUI&#39;s own constructor. <a href="https://github.com/fiddlerwoaroof/improvise/blob/main/src/draw.rs"><code>src/draw.rs</code></a>&#39;s one-line read of the flag stays argued-not-tested, as gotcha 48 has it.</p>
<p>Three injections. Putting the shared default back to <code>false</code> fails this test and <code>a_fresh_session_opens_with_the_category_panel</code>. Making <code>App::new</code> override the default (<code>app.view_state.category_panel_open = false</code>) fails this test <strong>and</strong> <code>the_terminal_constructor_inherits_the_shared_default_rather_than_setting_it</code> — which is the one that would catch a TUI-only launch state being reintroduced, the divergence <a class="tid" href="../tickets.html#improvise-wsaw" title="The browser ignores category_panel_open, so C toggles nothing visible">improvise-wsaw</a> closed. <code>the_other_two_panels_are_shut_at_launch</code> stops &quot;open everything at launch&quot; passing the first.</p>
<h3 id="set-panel-open-and-cursor"><a class="anchor" href="#set-panel-open-and-cursor">#</a>set_panel_open_and_cursor</h3>
<p><strong>The launch default silently disarmed this test, and that is the finding.</strong> It did <code>SetPanelOpen { Category, open: true }</code> then asserted the flag was true — but <code>test_app()</code> builds from <code>ViewState::default()</code>, where it is <em>already</em> true. Its Formula and View siblings still default to <code>false</code>, so only the category arm went vacuous.</p>
<p>What that cost, demonstrated: replacing <code>Panel::Category =&gt; app.view_state.category_panel_open = self.open</code> with a <strong>no-op</strong> — so no effect could open or close the TUI&#39;s category sidebar at all — left the entire workspace green. <code>C</code> could be made to do nothing in the terminal with 1391 tests passing.</p>
<p>The fix is the fixture stating its own precondition: close the panel first and assert it closed, so the effect has to move the flag before the <code>true</code> direction is asserted. Injecting the no-op now fails this test.</p>
<p>Note the harness cannot see this one, and that is correct rather than a gap: the browser folds <code>Command::SetPanelOpen</code> through <code>reduce_view</code>, a <em>different</em> three-way match in a different crate from <code>effect::SetPanelOpen</code>&#39;s. Two derivations of one mapping — which is why the <code>reduce_view</code> guard needed the same repair independently. <strong>Since <a class="tid" href="../tickets.html#improvise-56ek" title="SetPanelOpen maps a Panel to a field twice, in two crates">improvise-56ek</a> there is one derivation</strong>, so that is history rather than a standing hazard; see below.</p>
<h3 id="a-panel-command-writes-that-panel-and-no-other"><a class="anchor" href="#a-panel-command-writes-that-panel-and-no-other">#</a>a_panel_command_writes_that_panel_and_no_other</h3>
<p>The <code>reduce_view</code> half of the one above, and <strong>the first repair of it was still vacuous</strong> — worth recording because the mistake is subtle. Folding <code>[open: false, open: true]</code> and asserting the final state is <em>true</em> passes against a reducer that folds nothing at all: the default is already true, so neither command needs to land. Only asserting the <strong><code>false</code></strong> direction discriminates, because that is the one the default cannot supply. Caught by injection rather than by reading — the no-op fold reported NONE failing after the first fix.</p>
<p><a class="tid" href="../tickets.html#improvise-56ek" title="SetPanelOpen maps a Panel to a field twice, in two crates">improvise-56ek</a> then removed the second derivation entirely: <code>ViewState::panel_open_mut</code> and <code>panel_cursor_mut</code> (plus their read halves) are the one place a <code>Panel</code> becomes a field, and both <code>effect::SetPanelOpen::apply</code> and <code>reduce_view</code> call them. The cure is one home, not a test holding two copies in step (<code>doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)</code>), so there is deliberately no &quot;the two agree&quot; test — a wrong field in the one home is a failure in every caller.</p>
<p>This test replaces <code>panel_commands_address_the_named_panel_only</code>, which drove <code>Panel::Category</code> only and so could not tell one panel from another. It keeps that test&#39;s lesson by zeroing every panel before opening one.</p>
<p>Injected fault: point <code>Panel::Category</code>&#39;s arm at <code>formula_panel_open</code>. Fails by name for every panel in <code>Panel::ALL</code>.</p>
<p><strong>That injection alone was not enough, and the gap is gotcha 3&#39;s shape.</strong> The first version asserted only through <code>panel_open</code>/<code>panel_cursor</code> — the read halves of the mapping it was testing. A <em>consistent</em> permutation of all four accessors is a self-consistent bijection: injecting Formula↔Category into <code>panel_open_mut</code>, <code>panel_open</code>, <code>panel_cursor_mut</code> and <code>panel_cursor</code> together left the test passing, while two unrelated tests that read raw fields failed. It caught only a <strong>non-injective</strong> mapping, which is exactly the injection first recorded here. The test now reads the three field pairs by name once per panel, and the consistent permutation fails it: &quot;Formula is not the field Formula names&quot;.</p>
<p>Also corrected here: the claim that consolidation left &quot;no second copy to agree with&quot; was false when written. <code>resolve.rs</code> carried a fourth copy of the same <code>Panel</code> → <code>ViewState</code> three-way match, <strong>in the same crate on the same type</strong>; it is deleted and its three call sites go through <code>ViewState::panel_open</code>. What remains is <em>not</em> the same mapping: <code>registry.rs</code> maps <code>Panel</code> to <code>CmdContext</code> fields, a different type whose values are copied from <code>view_state</code>, and <code>resolve.rs</code>/<code>registry.rs</code> each map <code>Panel</code> to a (cursor, collection length) pair, which is a second derivation of something else and is <code>lockstep.rs</code>&#39;s subject.</p>
<h3 id="a-fresh-session-opens-with-the-category-panel"><a class="anchor" href="#a-fresh-session-opens-with-the-category-panel">#</a>a_fresh_session_opens_with_the_category_panel</h3>
<p>The user&#39;s ruling that the category panel is visible at launch, implemented as the <strong>shared</strong> <code>ViewState::default()</code> rather than a browser special case — a browser-only default is &quot;the page overrides the flag&quot;, which is the same defect as the &quot;the page ignores the flag&quot; bug <a class="tid" href="../tickets.html#improvise-wsaw" title="The browser ignores category_panel_open, so C toggles nothing visible">improvise-wsaw</a> fixed. The consequence, accepted deliberately, is that the TUI opens with the panel too.</p>
<p>The test also asserts the <em>other two</em> panels stay shut, so it cannot pass against a default that simply opens everything.</p>
<p>Two existing tests had to change, and both were pinning the old decision by <strong>inheriting</strong> it rather than stating it. <code>panel_focus_cycles_from_the_focused_panel_not_the_first</code> is about skipping a <em>closed</em> panel and took &quot;the category panel is closed&quot; from the default; it now spells that precondition. <code>with_categories()</code> is used by tests about a click <em>opening</em> the panel, so it now shuts it explicitly. Neither is the decision being re-made — the decision is re-made only in the harness, deliberately.</p>
<h4 id="the-launch-state-and-the-tile-ruling-in-the-browser"><a class="anchor" href="#the-launch-state-and-the-tile-ruling-in-the-browser">#</a>The launch state and the tile ruling in the browser</h4>
<p>Also merged into <a href="https://github.com/fiddlerwoaroof/improvise/blob/main/harness/tests/contract.spec.js"><code>harness/tests/contract.spec.js</code></a> rather than left in the deleted <code>pointer.spec.js</code> — see the note above.</p>
<p>Two states, one mechanism: the sidebar is visible with no keypress, <strong>and</strong> <code>C</code> still hides it. Proved by injection in a real browser: setting <code>el.categories.hidden = false</code> (the page ignoring the flag) fails on <code>toBeHidden</code>; setting the shared default back to <code>false</code> fails on <code>toBeVisible</code>. Different signatures, different halves.</p>
<p><strong>Only the second is a guard, and an earlier wording here got that wrong.</strong> It claimed &quot;either alone passes against the bug wsaw fixed&quot; — but under the new default, visible-at-launch passes for correct code <em>and</em> for a sidebar drawn unconditionally. The launch assertion states the <strong>ruling</strong>; the <code>toBeHidden</code> after the toggle is the only thing that still catches wsaw.</p>
<p>Note the first <code>C</code> now <strong>focuses</strong> rather than opens, since the panel is already open — <code>toggle-panel-and-focus</code> closes only when the panel is both open and focused — so the test presses it twice and the hide is the second press.</p>
<p>The tile tests were rewritten rather than adjusted, because the previous one asserted the behaviour the ruling reverses; its failure against the new code was itself the injection. The first-click test waits out a timeout before asserting the axis did not move, since a cycle would arrive asynchronously and an instantaneous assertion would pass against a slow one. ---</p>
<h2 id="web-tests-the-node-suite-and-what-was-injected-into-it"><a class="anchor" href="#web-tests-the-node-suite-and-what-was-injected-into-it">#</a><a href="https://github.com/fiddlerwoaroof/improvise/tree/main/web/tests"><code>web/tests/</code></a> — the Node suite, and what was injected into it</h2>
<p>No <code>###</code> headings here on purpose: <code>--check-evidence</code> resolves a heading to a Rust <code>fn</code>, and a JavaScript test name under one would read as an orphan. These are keyed by file.</p>
<p>Every test below was proved able to fail by patching the code it names, checking the patch was on disk, running the suite and reading the failure — never by the absence of an error. Thirteen injections, thirteen failures:</p>
<p><strong>What <code>shell.test.js</code> cannot reach, and the PR found out the hard way:</strong> it tests <code>createShell</code> in isolation, so it sees the watchdog RULE and never the <code>app.js</code> wiring that calls it. That distinction is now written into <a href="../code/clients.html"><code>context/code/clients.md</code></a> and guarded in <code>shell.spec.js</code>.</p>
<p><strong><code>shell.test.js</code></strong> — <code>socketUrl</code> stops upgrading <code>ws://</code> on an https page; <code>composeStatus</code> returns the model fact alone (which fails five tests, because the watchdog&#39;s messages are shell facts and vanish with it); <code>fail()</code> stops clearing the timer, so the watchdog <em>pre-empts</em> rather than being disarmed — the bug PR #13 fixed and PR #21 half-lost; <code>extentOf</code> stops subtracting the frame; <code>frameCostOf</code> stops counting the column-group line.</p>
<p><strong><code>grid_plan.test.js</code></strong> — the data-row index is taken from the row&#39;s position in <code>rows</code>, so a group header consumes one (the click coordinate bug gotcha 1 is about); the group label is tested for truthiness, so a group legitimately named <code>&quot;&quot;</code> draws as &quot;in no group&quot;; <code>typing</code> is <code>Boolean(model.editing)</code>, so an empty edit buffer reads as no editor open.</p>
<p><strong><code>serial.test.js</code></strong> — the chain is replaced by <code>Promise.resolve().then(...)</code>, which is what an <code>async</code> listener does: the slow first frame is overtaken. The suite also keeps that fault as a <em>passing</em> test in its own right, asserting the unchained pair really does come back swapped, so the ordering test cannot be green for want of a race.</p>
<p><strong><code>render_surface.test.js</code></strong> — a <code>pub sparkline: String</code> added to <code>RenderModel</code> is reported unpainted; deleting <code>paintTiles</code> reports <code>tiles</code> unpainted; a <code>getElementById</code> that names an id <code>index.html</code> has not got is reported. The first is the shape of the bug the four widgets shared, and the list it checks against currently names <code>doc_name</code>/<code>doc_dirty</code>/<code>doc_stored_as</code> — <a class="tid" href="../tickets.html#improvise-5jna" title="The browser page does not paint the title bar it is now told about">improvise-5jna</a>, which this test found rather than remembered.</p>
<p><strong><code>bundle/wasm_surface.test.js</code></strong> — <code>client.render_model()</code> renamed to <code>client.render_modal()</code> fails both its tests. It reads the generated <code>.d.ts</code>, so a missing bundle is an assertion failure and never a skip.</p>
<p><strong><a href="https://github.com/fiddlerwoaroof/improvise/blob/main/web/assemble.sh"><code>web/assemble.sh</code></a></strong> — dropping <a href="https://github.com/fiddlerwoaroof/improvise/blob/main/web/shell.js"><code>web/shell.js</code></a> from the copy list, with the page&#39;s own imports as the check. The first version of that guard anchored its <code>sed</code> at <code>^import</code>, so it could not see the <strong>multi-line</strong> import that is the only one it exists for, and the injection passed: the guard was written, landed, and blind. That is what the injection was for.</p>
<h2 id="harness-tests-contract-spec-js-eight-injections-into-the-served-page"><a class="anchor" href="#harness-tests-contract-spec-js-eight-injections-into-the-served-page">#</a><a href="https://github.com/fiddlerwoaroof/improvise/blob/main/harness/tests/contract.spec.js"><code>harness/tests/contract.spec.js</code></a> — eight injections into the served page</h2>
<p>Patched in <code>harness/serve/app/app.js</code>, which is what the browser loads, so no rebuild stands between the fault and the run. The sidebar ignoring <code>category_panel_open</code>; the tile bar, view panel, formula panel and help overlay each never drawn; <code>click_cell</code> sending a constant row; <code>click_category</code> sending a constant index; and the typed buffer drawn without its caret. All eight fail.</p>
<p>The last one did <strong>not</strong> fail at first: the test asserted the <code>typing</code> class and not the text, so a cell drawn blank passed. The assertion was strengthened until the injection failed, which is the only reason it is worth having — and it is the same lesson as the eleven tests above: a class is a flag, and a flag is not a pixel.</p>
<h3 id="every-advertised-command-answers-in-its-own-mode"><a class="anchor" href="#every-advertised-command-answers-in-its-own-mode">#</a>every_advertised_command_answers_in_its_own_mode</h3>
<p><a class="tid" href="../tickets.html#improvise-rmvx" title="Six advertised keys do nothing in the browser, and the guard is scoped to one mode">improvise-rmvx</a>. Gotcha 47 says an upstream key is a black hole, and <code>the_command_names_that_still_need_the_host_are_these</code> is the guard that was supposed to keep the list of them honest. It was built with <code>in_mode(AppMode::Normal)</code>, so it only ever asked about Normal-mode bindings: <code>add-record-row</code> (RecordsNormal) and <code>delete-formula-at-cursor</code> (FormulaPanel) were outside its reach entirely and could never have failed it. <strong>A named list that silently scopes itself to one mode is a guard that looks broader than it is.</strong></p>
<p>This asks it of every mode, and it asks it the only way that is not over-reporting. The naive version resolves every advertised name against an EMPTY <code>ClientState</code> and flags <code>edit-or-drill</code>, <code>commit-cell-edit</code>, <code>create-and-switch-view</code> and <code>page-prev</code>/<code>page-next</code> — but those are <em>handled</em> names refused by STATE, which is correct behaviour and not this bug. So the fixture is <code>furnished</code>: every widget has something in it, a basis is published, and a window is announced, so an <code>Upstream</code> can only mean the name is unhandled.</p>
<p>Two things it does NOT do, each because the first attempt did and was wrong. It does not call <code>step</code> with the advertised args: <code>advertised</code> carries <code>None</code> for &quot;any binding of this command&quot;, and <code>step</code>&#39;s numeric and mode arguments are mandatory, so that reports <code>move-selection</code>, <code>move-panel-cursor</code> and <code>move-tile-cursor</code> as gaps in every mode. And it does not tokenise the rendered hint string: <code>help::advertised</code> is a table of command NAMES, so the cross-reference is exact and anything reading the rendered text is solving a harder problem than exists. The keys come off <code>Keymap::keys_running</code>, which <code>labels_for</code> now shares so &quot;which keys&quot; and &quot;how are they spelled&quot; cannot give two answers.</p>
<p><strong>It watches BOTH silent variants, and the first version did not.</strong> <code>KeyOutcome</code> has two ways of saying nothing: <code>Upstream</code>, and <code>Ignored</code> — which <code>KeyOutcome::of(vec![])</code> produces whenever a resolver composes an empty vector. To a user they are the same black hole. The first version asserted <code>resolve(..) == Upstream(event)</code>, so <strong>three of the six commands this whole change exists to make audible could go completely silent with the entire evidence base green</strong>: degrading only <code>add-record-row</code>, <code>delete-formula-at-cursor</code> and <code>view-back</code> to <code>Step::Declined(Vec::new())</code> left 1397 Rust tests, 19 harness tests and 26 node tests passing, because no browser spec presses those three keys. Found on review of PR #71. The check is now <code>!matches!(.., Upstream(_) | Ignored)</code>.</p>
<p><strong>The generalisation is worth more than the fix.</strong> <code>KeyOutcome::of</code> turning an empty <code>Local</code> into an honest <code>Ignored</code> is good design and it is also a <strong>laundering</strong> step: every wrong answer that composes nothing arrives wearing the variant that is usually innocent. So a guard&#39;s discriminator has to be <em>state</em> — did anything the user can see change — and never the shape of the return value. That is exactly why the <code>+cat</code> injection in <code>a_locally_committed_creating_column_agrees_with_the_host</code> failed (it compares documents and cursors) and the decline injection passed everywhere (nothing compared a screen).</p>
<p>Widening the check costs ten more entries, all one legitimate boundary case: <code>move-panel-cursor</code> mirrors <code>MovePanelCursor</code>, which returns nothing when the cursor does not move, so a one-row panel or a cursor on an edge answers <code>Ignored</code> for a reason that is not this test&#39;s subject. <code>furnished</code> therefore carries <strong>three</strong> of everything with all three panel cursors seated at index 1. Note what that rules out: seating them at 0 and calling the <code>Up</code> results expected would have hidden the real defect in the same list as the boundary case.</p>
<p><strong>The injection</strong>: delete the <code>&quot;toggle-prune-empty&quot;</code> arm from <code>declined</code>. The test fails naming <code>NORMAL: Char(&#39;P&#39;)</code> and <code>RECORDS: Char(&#39;P&#39;)</code> — both modes, which is the widening the ticket was about, and a build that ran rather than a compile error. The review&#39;s own injection is also kept as a check: degrading the three unpressed declines to empty now fails naming <code>FORMULAS: Char(&#39;d&#39;)</code>, <code>FORMULAS: Delete</code>, <code>RECORDS: Char(&#39;&lt;&#39;)</code> and <code>RECORDS: Char(&#39;o&#39;)</code>.</p>
<p>What it found that the ticket did not predict: <code>view-back</code> is advertised in RecordsNormal (<code>&lt;</code>, captioned &quot;back&quot;), so it was a seventh instance of the same class. The ticket had it filed under the Normal-mode host list instead, where being upstream is not a defect.</p>
<p>The five that remain are listed by name with a reason each. Three of them — <code>commit-export</code>, <code>commit-save</code>, <code>execute-command</code> — are reachable only from a mode <code>declined</code> refuses at the door, so a browser never stands in front of the hint; the other two are <a class="tid" href="../tickets.html#improvise-gcvb" title="The browser cannot commit a records-mode creating column, though it is now nameable">improvise-gcvb</a>&#39;s records-cell commit, which needs <code>record_col</code>.</p>
<h3 id="a-locally-committed-creating-column-agrees-with-the-host"><a class="anchor" href="#a-locally-committed-creating-column-agrees-with-the-host">#</a>a_locally_committed_creating_column_agrees_with_the_host</h3>
<p><a class="tid" href="../tickets.html#improvise-gcvb" title="The browser cannot commit a records-mode creating column, though it is now nameable">improvise-gcvb</a>. <code>resolve</code>&#39;s commit returned <code>None</code> for the whole of records mode, and the reason given was sound for the coordinate and measure columns — a category column renames every measure cell in the row, which needs <code>record_col</code> (gotcha 1). The guard was broader than its reason: a cell at the cursor now projects <code>CellId::Create</code>, and committing one is a single existing <code>ModelCommand</code> over the typed NAME that reads no <code>record_col</code> and writes no cell.</p>
<p>Both halves are proved by injection, and the second is the one that matters. Putting the broad <code>is_records()</code> early return back makes the test fail at &quot;the client must answer this key locally&quot;. Deleting the <code>CreateKind::Category</code> early return — so <code>+cat</code> advances like any other column — fails with the client in <code>RecordsEditing</code> where the host is in <code>RecordsNormal</code>: the TUI returns straight after a <code>+cat</code> commit with a <code>RebuildLayout</code> and no cursor move, because an inserted column renumbers every index to its right and the advance was computed from the pre-commit layout (gotcha 23). A client that advanced anyway would land one column off, silently.</p>
<p><code>both_ends</code> compares the document as <code>.improv</code> text, the mode, the status and every cursor, so neither half can pass by agreeing on the wrong thing. The fixture drives <em>both</em> hosts into records mode with the <code>R</code> key rather than hand-building a <code>_Records</code> view, because <code>twin_of</code> copies only the session slice and a twin without the view would refuse the commit for the wrong reason.</p>
<h3 id="every-minibuffer-mode-paints-what-is-being-typed"><a class="anchor" href="#every-minibuffer-mode-paints-what-is-being-typed">#</a>every_minibuffer_mode_paints_what_is_being_typed</h3>
<p><a class="tid" href="../tickets.html#improvise-fdt6" title="A browser cannot add a formula, so FormulaEdit is a room that swallows typing">improvise-fdt6</a>. <code>commit-buffer formula</code> already resolved on the client — that half of the ticket was closed by an earlier wave — so the browser could enter FormulaEdit, type a formula and commit it. What did not exist was any way to SEE it: <code>RenderModel::editing</code> is populated only when <code>mode.is_editing()</code>, which is the two <em>cell</em>-editing modes, and it reads the <code>&quot;edit&quot;</code> buffer by name. Every character typed into FormulaEdit, CategoryAdd, ItemAdd, SavePrompt, ExportPrompt or CommandMode went into a buffer nothing painted. The mode opened, the user typed, and the screen did not move — which is exactly the dead-room shape the ticket names, arrived at from the rendering side rather than the resolution side.</p>
<p>The value is read with <code>improvise_protocol::read_buffer</code> rather than <code>buffers.get</code>, so the <code>&quot;search&quot;</code> special case (gotcha 11) keeps its one home.</p>
<p><strong>The injection</strong>: <code>.filter(|_| state.view.mode.is_editing())</code> on the new field, which is the pre-fix behaviour exactly. It fails naming <code>&quot;formula&quot;</code>.</p>
<p><strong>And the static check was not enough, which is the part worth keeping.</strong> <a href="https://github.com/fiddlerwoaroof/improvise/blob/main/web/tests/render_surface.test.js"><code>web/tests/render_surface.test.js</code></a> asserts that every <code>pub</code> field of <code>RenderModel</code> appears as <code>model.&lt;field&gt;</code> somewhere in <code>app.js</code>. It passed while the page was still broken: the string <code>model.minibuffer</code> was inside <code>paintMinibuffer</code>, and <code>paintMinibuffer</code> was never called and <code>el.minibuffer</code> was never looked up. A field can be <em>mentioned</em> by a function nothing invokes. <a href="https://github.com/fiddlerwoaroof/improvise/blob/main/harness/tests/keys.spec.js"><code>harness/tests/keys.spec.js</code></a> caught it on the first run, in the browser, which is the whole argument for that directory existing (gotcha 48).</p>
<h3 id="nothing-declined-is-also-composed"><a class="anchor" href="#nothing-declined-is-also-composed">#</a>nothing_declined_is_also_composed</h3>
<p><code>step</code> is <code>declined</code> then <code>composed</code>, and until this test the claim that the two lists cannot overlap was a <strong>doc comment</strong>. The dangerous direction is not a typo: it is a future ticket implementing <code>view-back</code> (or <code>toggle-prune-empty</code>, which is one projection field away) in <code>composed</code> and not thinking to delete its <code>declined</code> arm. The browser then goes on saying &quot;not available in the browser yet&quot; while the working code sits unreachable, and nothing notices — the classic shape of a check passing while the thing it names is broken. Raised on review of PR #71.</p>
<p>It is <strong>derived from <code>declined</code> itself</strong>, not from a list beside it: it walks every <code>(name, args)</code> bound in every mode&#39;s keymap — parents and prefix sub-keymaps included, sequence steps included — and for each one <code>declined</code> answers, asserts <code>composed</code> does not. A seventh decline is covered without coming here, which is the property a hand-written list could not have.</p>
<p><strong>The injection</strong>: add <code>&quot;view-back&quot; =&gt; Some(vec![Command::SwitchView(..)])</code> to <code>composed</code>. It fails naming <code>NORMAL: view-back []</code> and <code>RECORDS: view-back []</code>.</p>
<p>It also found a real overlap on its first run. <code>enter-mode</code> is answered by <code>declined</code> for the three stranding modes <em>and</em> by <code>composed</code> for every mode, so <code>composed</code>&#39;s arm would have opened <code>SavePrompt</code>, <code>ExportPrompt</code> or <code>CommandMode</code> if <code>declined</code> were ever bypassed. Rather than moving the check, <strong>both halves now hold the rule</strong> — <code>composed</code> returns <code>None</code> for a stranded mode — so the failure direction if one is removed is the pre-PR <code>Upstream</code>, not a dead room. ---</p>
<h2 id="harness-tests-help-spec-js-the-overlay-asserted-as-words"><a class="anchor" href="#harness-tests-help-spec-js-the-overlay-asserted-as-words">#</a><a href="https://github.com/fiddlerwoaroof/improvise/blob/main/harness/tests/help.spec.js"><code>harness/tests/help.spec.js</code></a> — the overlay, asserted as words</h2>
<p>PR #61 deleted <code>&quot;? opens help, and its rows are the shared ones&quot;</code> and nothing replaced it, so <code>paintHelp</code>, <code>help</code>, <code>help_page</code> and <code>help_titles</code> reached no assertion at all (<a class="tid" href="../tickets.html#improvise-78v6" title="PR #61 deleted the help-overlay spec and nothing replaced it">improvise-78v6</a>). What <code>contract.spec.js</code> carried in its place said only that a <code>dt</code> was VISIBLE — and an empty <code>dt</code> is a visible <code>dt</code> (<a class="tid" href="../tickets.html#improvise-igxp" title="The browser help overlay&#39;s rows are asserted as present, never as content">improvise-igxp</a>), which is the same shape as the caret bug above: a row is a frame, and a frame is not a word.</p>
<p><strong>The injection both tickets name</strong>: blank <code>keys.textContent</code> in <code>paintHelp</code>, patched in <code>harness/serve/app/app.js</code>. Before this work it left all three contract tests passing. It now fails both tests in this file (the Welcome banner reads empty; the advertised key reads empty) <strong>and</strong> <code>contract.spec.js</code>, whose two help lines were strengthened from <code>toBeVisible</code> to the banner&#39;s text and the first <code>dt.help-keys</code>&#39;s text.</p>
<p><strong>What the pairs helper is for.</strong> The overlay is a <code>&lt;dl&gt;</code>, and the association between a key row and its description is document order — <code>dt</code> then <code>dd</code>. Read as two lists they zip wrong, because a prose row emits <strong>no</strong> <code>dd</code> at all (deliberately: an empty one would leave a blank grid cell). Welcome is prose end to end, so <code>dd</code> count zero there is an assertion about that rule and not a coincidence.</p>
<h4 id="a-key-the-overlay-advertises-does-what-the-overlay-says-it-does"><a class="anchor" href="#a-key-the-overlay-advertises-does-what-the-overlay-says-it-does">#</a>&quot;a key the overlay advertises does what the overlay says it does&quot;</h4>
<p>The assertions above are still about a string on a screen; this one asks whether the string is <strong>true</strong>. The key for <code>Jump to last row</code> is read OUT OF THE DOM, the overlay closed, the key pressed, and the selection asserted onto the last row. A page drawing a plausible key it does not have — what a hand-spelled table decays into — fails here, and the spec holds no second copy of the keymap.</p>
<p>It presses the FIRST key the row names because a row is a compaction of every binding that reaches the command (<code>h j k l ← ↑ → ↓</code> is eight).</p>
<h2 id="harness-tests-grouped-spec-js-the-first-browser-run-of-improvise-anr9"><a class="anchor" href="#harness-tests-grouped-spec-js-the-first-browser-run-of-improvise-anr9">#</a><a href="https://github.com/fiddlerwoaroof/improvise/blob/main/harness/tests/grouped.spec.js"><code>harness/tests/grouped.spec.js</code></a> — the first browser run of <a class="tid" href="../tickets.html#improvise-anr9" title="Project the five grid-frame facts: unblocks H, z, gz and fixes Food/Alice at once">improvise-anr9</a></h2>
<p><a href="https://github.com/fiddlerwoaroof/improvise/blob/main/web/demo.improv"><code>web/demo.improv</code></a> has no groups and one category per visible axis, deliberately (gotcha 46), so the multi-level header, the group span, the collapse indicator and the inline group row — exactly the code <a class="tid" href="../tickets.html#improvise-anr9" title="Project the five grid-frame facts: unblocks H, z, gz and fixes Food/Alice at once">improvise-anr9</a> added — had never been rendered anywhere a test could see (<a class="tid" href="../tickets.html#improvise-plcx" title="The browser harness needs a grouped, multi-level fixture — the demo has neither">improvise-plcx</a>). The fixture is <a href="https://github.com/fiddlerwoaroof/improvise/blob/main/harness/fixtures/grouped.improv"><code>harness/fixtures/grouped.improv</code></a>, served beside the shipped demo by <a href="https://github.com/fiddlerwoaroof/improvise/blob/main/harness/build.sh"><code>harness/build.sh</code></a>, and the shipped document is unchanged.</p>
<p><strong>A missing seed is not an error</strong>: <a href="https://github.com/fiddlerwoaroof/improvise/blob/main/web/worker.js"><code>web/worker.js</code></a> answers a fetch that 404s with an empty model. So a typo in the filename yields a page that draws a grid, and &quot;a grid is drawn&quot; would pass on it. Every assertion here is about labels the fixture really carries.</p>
<p><strong><code>openGrouped</code> waits with retrying assertions and the tests then read once.</strong> The head and the row labels are read in a single <code>evaluate</code> each, because what these tests assert is the RELATION between the lines — and a one-shot read cannot wait. The grid is painted from the first projection and reframed by the viewport the page then announces, so the helper gates on the head&#39;s shape, its first group and the first row&#39;s innermost label before anything is read.</p>
<p><strong>Five injections, five distinct failures</strong>, patched in <code>harness/serve/</code> so no rebuild stands between the fault and the run:</p>
<ul>
<li><code>gridPlan</code> slash-joins the level tuples into one line (the exact defect anr9 fixed) — fails all three, because <code>openGrouped</code> waits on the head&#39;s shape before any test reads it;</li>
<li><code>th.colSpan = 1</code> on every column group header — fails the span test alone;</li>
<li>a stray <code>tr.insertCell()</code> on the inline group row — fails the group-row test alone, which is the DOM half of <code>RenderRow::Group</code> carrying no cells;</li>
<li><code>indicator(false)</code> hard-coded — fails on <code>▶ Upland</code> while <code>▼ Inland</code> still passes, which is why the fixture has one collapsed group and one open one;</li>
<li><code>collapsed: Region/Upland</code> removed from the served document — fails on the rows that reappear, so the assertions see the collapse and not just the glyph.</li>
</ul>
<p><strong>A bug this fixture surfaced rather than predicted.</strong> <code>Coastal</code> is the row frame&#39;s FIRST entry and its header is never drawn: the spec asserts <strong>two</strong> group rows where the document has three, with <a class="tid" href="../tickets.html#improvise-0o6x" title="A group header first on its axis is never drawn, so a leading collapsed group has no way back">improvise-0o6x</a> named on the line. That count is a record of an open bug, not an endorsement — when 0o6x lands it becomes 3 and this line is where the change must be argued.</p>
<h2 id="harness-tests-landing-spec-js-the-front-door"><a class="anchor" href="#harness-tests-landing-spec-js-the-front-door">#</a><a href="https://github.com/fiddlerwoaroof/improvise/blob/main/harness/tests/landing.spec.js"><code>harness/tests/landing.spec.js</code></a> — the front door</h2>
<p><a href="https://github.com/fiddlerwoaroof/improvise/blob/main/docs/index.html"><code>docs/index.html</code></a> is copied to the site root by <a href="https://github.com/fiddlerwoaroof/improvise/blob/main/web/assemble.sh"><code>web/assemble.sh</code></a>, so the harness has been serving it all along with nothing asserted about it (<a class="tid" href="../tickets.html#improvise-bgtf" title="The landing page is served by the harness and asserted by nothing">improvise-bgtf</a>).</p>
<p><strong>Counting the four <code>.ap-player</code> elements proves nothing</strong>, and that is the whole reason this is a spec rather than a one-liner: <code>autoPlay</code> is false, so asciinema-player fetches no cast until play is pressed. A page whose every cast path 404s constructs four players, throws no page error, and looks identical to a working one. Injected while the page was being written (<code>./casts/</code> -&gt; <code>./cats/</code>) the construction-only check passed on the broken page.</p>
<p>Re-injected into <code>harness/serve/index.html</code> now, the spec fails at the first player: <code>.ap-line</code> elements exist only once there is a cast to lay out. The spec also asserts, BEFORE pressing anything, that no <code>.cast</code> has been requested — without which the network evidence below could be satisfied by a page that fetched them eagerly, which is not the page we ship.</p>
<p>Two assertions where one looks sufficient: the rendering says a cast played, and the recorded responses say <strong>four distinct</strong> casts answered 200. Three players showing the same recording satisfies the first and fails the second.</p>
<p><code>&quot;the front door reaches the app it advertises&quot;</code> follows the footer link rather than asserting its text, because whether <code>./app/</code> resolves is a fact about the assembled tree. Injecting <code>href=&quot;./aplication/&quot;</code> fails it.</p>
<h2 id="harness-tests-contract-spec-js-the-ninth-injection-and-a-lost-guard"><a class="anchor" href="#harness-tests-contract-spec-js-the-ninth-injection-and-a-lost-guard">#</a><a href="https://github.com/fiddlerwoaroof/improvise/blob/main/harness/tests/contract.spec.js"><code>harness/tests/contract.spec.js</code></a> — the ninth injection, and a lost guard</h2>
<p><strong>The lost guard.</strong> <a class="tid" href="../tickets.html#improvise-78v6" title="PR #61 deleted the help-overlay spec and nothing replaced it">improvise-78v6</a> asks for the test-name diff over PR #61&#39;s range, and it finds a fourth loss beyond the help test: <code>&quot;a click on an item row filters, as Enter does&quot;</code>. <code>contract.spec.js</code> asserts the CATEGORY arm of <code>click_category_row</code> (the row expands) and nothing asserts the ITEM arm, whose rule is &quot;do what Enter does here&quot; — filter. Only <code>#status</code> distinguishes them, so a click wired to the wrong arm passed the whole file. Restored inside the click test, and proved by injecting <code>click_category(row.is_category ? index : 0)</code>, which fails that line alone.</p>
<p>The diff that finds this is two commands, and is worth running on any PR whose subject is a lost guard:</p>
<pre><code>M=$(gh pr view 61 --json mergeCommit -q .mergeCommit.oid)
for r in &quot;$M^1&quot; &quot;$M&quot;; do
git ls-tree -r --name-only &quot;$r&quot; -- harness/tests web/tests |
while read -r f; do git show &quot;$r:$f&quot; | grep -oE &#39;test\(&quot;[^&quot;]*&quot;&#39;; done
done</code></pre>
<p>The other two losses are deliberate: <code>pointer.spec.js</code>&#39;s sidebar test was superseded by the open-at-launch ruling, and the widgets file was merged into this one.</p>
<h3 id="the-keys-that-compose-a-switch-view-are-these"><a class="anchor" href="#the-keys-that-compose-a-switch-view-are-these">#</a>the_keys_that_compose_a_switch_view_are_these</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> is deferred on a <strong>premise</strong>, not a judgement: its own note says it is &quot;unreachable today ONLY because no shipped client emits <code>Command::SwitchView</code>&quot;, and asks for a guard asserting exactly that, so the silent failure becomes loud the moment the premise stops holding.</p>
<p>The guard failed on its first run. Two live paths in this client&#39;s own resolver (<code>resolve.rs</code>): <code>switch-view-at-cursor</code> and <code>create-and-switch-view</code>. The other half was already live — the same resolver composes <code>ModelCommand::TransposeAxes</code>. So the scenario lltt describes is reachable today from the shipped view panel, and the ticket was escalated P2 → P1 rather than fixed here, the honest fix being the protocol change it describes.</p>
<p><strong>It is an <code>assert_eq</code> against those two names, not an assert-empty, and the list is not an exemption list.</strong> It pins what is true so a <em>third</em> live path fails, and its message says so — because a reader who found this passing would otherwise conclude the premise holds. When lltt is fixed the list empties and this test goes with it.</p>
<p>Why the enumeration and not a read: the bug was already written into a passing test&#39;s comment elsewhere (<code>&quot;Aimed at the host&#39;s own view… HideItem writes view_mut()&quot;</code>) and had been read as a fixture note for as long as it had been there.</p>
<h4 id="a-click-moves-the-selection-from-an-editing-state-too"><a class="anchor" href="#a-click-moves-the-selection-from-an-editing-state-too">#</a>&quot;a click moves the selection from an editing state too&quot;</h4>
<p><a class="tid" href="../tickets.html#improvise-0x9u" title="A click cannot move the grid selection while an editor is open">improvise-0x9u</a>, and the fault was injected by running this assertion against the bundle as it stood: <code>cells.nth(4).locator(&quot;input&quot;)</code> came back <strong>0</strong>, with the selection still on cell 0, exactly as the bug report measured in Chromium.</p>
<p>Two things about it are worth more than the assertion. First, <strong>what it does not do</strong>: it never presses Escape between the two clicks. The sibling test does, and that single line is why the property it names (&quot;a click moves the selection&quot;) was asserted for a whole wave while the neighbouring state was broken — the click path from an <em>editing</em> page was never exercised.</p>
<p>Second, <strong>the fix&#39;s own first attempt failed this same test</strong>, which is the part to remember. Moving the handler to <code>mousedown</code> is necessary and not sufficient: <code>close</code> still repainted, and removing a focused <code>&lt;input&gt;</code> fires <code>blur</code> <strong>synchronously</strong>, so <code>close</code> rebuilt the table from inside itself and detached the <code>&lt;td&gt;</code> the handler was about to open an editor on. The assertion could not tell that apart from the original bug — same symptom, different cause — so it is the debug run rather than the test that separated them. <code>close</code> therefore no longer repaints, and the <code>blur</code> arm checks <code>closed</code> before dismissing.</p>
<p>Third — and this is the edit the repair nearly shipped unguarded — <strong>the fix is three edits, not two</strong>, and the third is the one with the most careful comment: <code>close</code> restores <code>td.textContent</code> (the <em>drawn</em> value) instead of <code>seed</code> (the <em>stored</em> one), because <code>close</code> no longer repaints and <code>repaint()</code> early-returns for as long as <code>editing</code> is set. Reverting that one line to <code>td.replaceChildren(original)</code> left the whole suite green, and the defect was on the front page: <a href="https://github.com/fiddlerwoaroof/improvise/blob/main/web/demo.improv"><code>web/demo.improv</code></a> carries <code>format: ,.0</code> over <code>18400</code>, and this test&#39;s own two clicks — no Escape between them — leave cell 0 reading <code>18400</code> where the page had drawn <code>18,400</code>, for as long as the second editor stays open. The assertion added for it is <code>toHaveText(&quot;18,400&quot;)</code> after the second click, and the injection is exactly that revert: it fails with <code>unexpected value &quot;18400&quot;</code>, naming this test and no other.</p>
<p>The pattern is worth carrying past this entry. A repair of N edits whose N−1 guarded ones pass is not a guarded repair, and the unguarded edit is reliably the one the author understood best — which is why it carries the clearest comment and why it felt covered. For each distinct edit, name the test that fails when only that edit is reverted; a line with a comment and no answer to that question is the line to inject.</p>
<h4 id="the-widgets-the-demo-document-fills-are-drawn-in-the-state-that-fills-them"><a class="anchor" href="#the-widgets-the-demo-document-fills-are-drawn-in-the-state-that-fills-them">#</a>&quot;the widgets the demo document fills are drawn, in the state that fills them&quot;</h4>
<p>The <code>#doc-name</code> row is <a class="tid" href="../tickets.html#improvise-5jna" title="The browser page does not paint the title bar it is now told about">improvise-5jna</a>, and the reason it belongs in a <strong>browser</strong> test rather than in <code>render_surface.test.js</code> is that the latter is a substring search over the page&#39;s source: <code>doc_name</code> mentioned by a function nothing calls reads as painted. That is how <code>model.minibuffer</code> once shipped dead, and how z754&#39;s three title-bar fields sat projected and unpainted for a wave while the static check was green. Proved by adding the assertion first: <code>element(s) not found</code> for <code>#doc-name</code>.</p>
<p>The <code>[+]</code> marker is deliberately <strong>not</strong> asserted here. The standalone deployment persists on every accepted batch and <code>effect::Save</code> clears the flag in the same round trip (gotcha 45), so the marker is transient in a browser and any timing assertion over it would be flaky rather than discriminating. Its spelling — the order <code>name (stored_as) [+]</code>, the <code>null</code> <code>stored_as</code> writing no empty parens — is <a href="https://github.com/fiddlerwoaroof/improvise/blob/main/web/tests/shell.test.js"><code>web/tests/shell.test.js</code></a>, over <code>documentTitle</code> directly.</p>
<h4 id="a-selection-driven-below-the-fold-scrolls-itself-into-view"><a class="anchor" href="#a-selection-driven-below-the-fold-scrolls-itself-into-view">#</a>&quot;a selection driven below the fold scrolls itself into view&quot;</h4>
<p><a class="tid" href="../tickets.html#improvise-jq8l" title="web/app.js never calls scrollIntoView, so a selection can sit outside the window">improvise-jq8l</a>, and the expensive half was the <strong>fixture</strong>, not the assertion. Neither shipped document can reach the state: <a href="https://github.com/fiddlerwoaroof/improvise/blob/main/web/demo.improv"><code>web/demo.improv</code></a> is four rows and <a href="https://github.com/fiddlerwoaroof/improvise/blob/main/harness/fixtures/grouped.improv"><code>harness/fixtures/grouped.improv</code></a> is eight, and a probe measured both at <code>scrollHeight === clientHeight</code> in a 484px box — nothing to scroll, so the assertion would have passed against a page that never scrolls. That is the vacuity this entry exists to record, and it is why the spec asserts <code>scrollHeight - clientHeight &gt; 0</code> <strong>before</strong> it presses a key.</p>
<p><a href="https://github.com/fiddlerwoaroof/improvise/blob/main/harness/fixtures/tall.improv"><code>harness/fixtures/tall.improv</code></a> is thirty rows in three groups, which overflows for a reason worth stating: <code>ROW_PX</code> is 26 and a real row measures 28.6px, and inline group rows are not charged for at all, so the page renders more rows than its own box can show. Measured against the unfixed page: 105px of overflow, the selected cell 96px below the visible bottom, <code>scrollTop</code> still 0 after twenty-five <code>ArrowDown</code>s.</p>
<p>Both directions are asserted, and neither alone is enough: the cell being inside <code>#grid</code>&#39;s rect would also hold if the selection had never moved, and <code>scrollTop &gt; 0</code> would also hold if the page scrolled to the wrong place.</p>
<h4 id="the-casts-are-legible-on-a-phone-and-the-page-still-does-not-scroll-sideways"><a class="anchor" href="#the-casts-are-legible-on-a-phone-and-the-page-still-does-not-scroll-sideways">#</a>&quot;the casts are legible on a phone, and the page still does not scroll sideways&quot;</h4>
<p><a class="tid" href="../tickets.html#improvise-o31u" title="The landing page&#39;s casts render at a 4.8px font on a phone, and it is now the site root">improvise-o31u</a>, and <strong>the instrument is the finding</strong>. <code>getBoundingClientRect</code> reports a height for a <code>content-visibility</code>-hidden child, and heading text overflows its box without widening it, so neither &quot;the element is present&quot; nor a box measurement can see this defect at all. What a reader sees is the <strong>computed font size</strong>, and that is what is asserted.</p>
<p>Measured against the unfixed page, and it reproduces the ticket&#39;s figure to five digits: <code>pivot.cast</code> renders its terminal at <strong>4.83258px</strong> on a 390px viewport. The arithmetic is not subtle — the player is told <code>cols: 120</code>, the container is ~356px inside the page&#39;s 16px gutters, and the player DERIVES its font size from the width it is given. After the fix: <strong>11.9457px</strong>.</p>
<p>Two assertions guard the trade this fix must not make. The page&#39;s own <code>scrollWidth - clientWidth</code> must stay <strong>0</strong> — a document that scrolls sideways on a phone is a worse bug than one unreadable section — and <code>#cast-pivot</code> must be the thing that scrolls, which is what separates &quot;contained the overflow&quot; from &quot;removed the overflow by shrinking something else&quot;.</p>
<h4 id="a-desktop-viewport-is-unchanged-by-the-phone-fix"><a class="anchor" href="#a-desktop-viewport-is-unchanged-by-the-phone-fix">#</a>&quot;a desktop viewport is unchanged by the phone fix&quot;</h4>
<p>The negative arm, and <strong>it is forward-looking: no fault available today makes it fail.</strong> The entry previously named two mechanisms and both were measured, at a 1280px viewport, against the shipped page:</p>
<ul>
<li><code>@media (max-width: 900px)</code> → <code>@media all</code>: this test <strong>passes</strong> (13.1131px, <code>scrollWidth === clientWidth</code>).</li>
<li><code>min-width: 880px</code> moved from <code>.player &gt; *</code> onto <code>.player</code>: this test <strong>passes</strong>; the <em>phone</em> arm fails instead, on <code>scrolls.page === 0</code>.</li>
</ul>
<p>The arithmetic is why, and it is worth stating rather than re-deriving: at 1280px <code>#cast-pivot</code> measures <code>clientWidth === scrollWidth === 966</code>, so an 880px floor is inert at desktop wherever it is placed. The floor would have to exceed the ~966px the player actually gets — or the content column would have to narrow — before either assertion here could move. That is a real future fault and the reason to keep the test: it pins 13.11px and &quot;nothing to scroll&quot; against a floor raised past the player&#39;s own width. It is not a reason to claim the arm currently discriminates between the two placements, because it does not.</p>
<p>Note also that the <strong>phone</strong> arm is stronger than this entry used to credit it with: the misplaced-<code>min-width</code> fault, attributed here to the desktop arm, is caught there. <code>scrolls.page === 0</code> is the assertion that separates &quot;contained the overflow&quot; from &quot;moved it onto the document&quot;.</p>
<h4 id="no-cast-carries-the-recording-machine-s-shell-clock-or-geometry"><a class="anchor" href="#no-cast-carries-the-recording-machine-s-shell-clock-or-geometry">#</a>&quot;no cast carries the recording machine&#39;s shell, clock or geometry&quot;</h4>
<p><a class="tid" href="../tickets.html#improvise-2oxt" title="The landing page&#39;s casts predate the records columns and show the recorder&#39;s shell prompt">improvise-2oxt</a>, and <strong>both clauses were injected separately</strong>, which matters because the first would otherwise mask the second. Serving the pre-2oxt <code>pivot.cast</code> fails on <code>env</code> — <code>Received: {&quot;SHELL&quot;: &quot;/bin/zsh&quot;, &quot;TERM&quot;: &quot;screen-256color&quot;}</code> — and stops there. Stripping <code>env</code> and <code>timestamp</code> from that same header and serving the body unchanged then fails on <code>pivot.cast sets a window title</code>, which is the clause that actually watches for the leak: an OSC window-title sequence, emitted by a shell once per prompt and carrying the cwd, and emitted by <code>improvise</code> never.</p>
<p>Asserted over the cast <strong>file</strong> rather than over the rendering, and that is the finding. A player draws a shell prompt as ordinary terminal text, so a screenshot or a <code>toContainText</code> assertion would have to know what the hostname and home directory had been — a check that can only be written by someone who has already seen the leak. The file-level signature can be written by anyone.</p>
<p>What no assertion here reaches, and how it was checked instead: whether each cast SHOWS what its section claims. That is not a property of the bytes. It was verified by replaying each recording through a small ANSI reconstruction and reading the frames as text, which found two scenarios that recorded cleanly and demonstrated nothing — a drill into a cell with no records behind it, and a <code>c</code> pressed on a category already on the column axis. Both are true frames about the wrong thing, which is the failure mode this repo keeps meeting, and neither was visible from the keystroke list.</p>
<hr />
<h2 id="improvise-ws-server-the-origin-check-improvise-kc7n"><a class="anchor" href="#improvise-ws-server-the-origin-check-improvise-kc7n">#</a><code>improvise-ws-server</code> — the <code>Origin</code> check (<a class="tid" href="../tickets.html#improvise-kc7n" title="improvise-ws-server never checks Origin, so any page a user visits can drive their session">improvise-kc7n</a>)</h2>
<p>The bug: <code>grep -rni origin crates/improvise-ws-server/src/</code> returned nothing. The server inspected no <code>Origin</code> header and accepted every handshake, and a websocket handshake is exempt from the same-origin policy — no CORS preflight — so any page a user visited could open a socket from that user&#39;s own allowed address and read and edit every document served. An address check cannot see it; the argument is <a href="../code/clients.html"><code>context/code/clients.md</code></a> §Origin.</p>
<p>Five edits, five guards. Each was reverted alone and the named test failed; nothing here is covered only by a comment. Review added a sixth — see <code>two_origins_state_none_this_policy_can_answer_for</code>.</p>
<h3 id="a-handshake-from-a-foreign-origin-is-refused"><a class="anchor" href="#a-handshake-from-a-foreign-origin-is-refused">#</a>a_handshake_from_a_foreign_origin_is_refused</h3>
<p>The reproduction, written first and watched fail on <code>2afbf37</code>: a handshake carrying <code>Origin: https://evil.example</code> connected, and the test&#39;s <code>connect_async(...).err()</code> was <code>None</code>. After the fix the upgrade is refused with a 403 and the assertion is on the status, not merely on &quot;an error happened&quot; — a refused upgrade and a dropped connection are different outcomes and only one of them is the fix.</p>
<p><strong>Injection:</strong> replacing the handshake callback&#39;s <code>admitted(&amp;origins, req)</code> with <code>Ok(())</code> — the transport collecting a policy it never asks — fails this and <code>a_named_origin_reaches_the_document</code>, and nothing else in the workspace.</p>
<h3 id="a-handshake-with-no-origin-is-accepted"><a class="anchor" href="#a-handshake-with-no-origin-is-accepted">#</a>a_handshake_with_no_origin_is_accepted</h3>
<p>The case most likely to be wrong later, so it is named rather than left to a match arm. Non-browser clients send no <code>Origin</code>: curl, a native client, this file&#39;s own other tests. <strong>Injection:</strong> flipping <code>OriginPolicy::permits</code>&#39;s <code>None</code> arm to <code>false</code> fails this, <code>an_absent_origin_is_allowed</code>, <code>the_handshake_refuses_what_the_policy_refuses</code>, and <em>both pre-existing socket tests</em> — which is the point: the repo&#39;s own instrument is the non-browser client this arm is about.</p>
<h3 id="a-handshake-from-a-loopback-page-is-accepted"><a class="anchor" href="#a-handshake-from-a-loopback-page-is-accepted">#</a>a_handshake_from_a_loopback_page_is_accepted</h3>
<p>The shipped local recipe — the page served by <code>python3 -m http.server 8080</code>, the socket on another loopback port — must keep working with no flag, or the default is a different bug from the one being fixed. <strong>Injection:</strong> making <code>is_loopback</code> return <code>false</code> fails this and <code>a_loopback_page_is_allowed_by_default</code> and leaves the refusal tests green, which separates &quot;allows loopback&quot; from &quot;allows everything&quot;.</p>
<h3 id="a-named-origin-reaches-the-document"><a class="anchor" href="#a-named-origin-reaches-the-document">#</a>a_named_origin_reaches_the_document</h3>
<p><code>--allow-origin</code> end to end over a real socket, plus the negative arm in the same test: widening for one page does not widen for its neighbours. Without that second assertion the test passes against a policy that permits everything, which is the fault E1a injects.</p>
<h3 id="allow-origin-widens-the-policy-and-nothing-else-does"><a class="anchor" href="#allow-origin-widens-the-policy-and-nothing-else-does">#</a>allow_origin_widens_the_policy_and_nothing_else_does</h3>
<p>The wiring, which no socket test can reach: the other tests build an <code>OriginPolicy</code> directly, so a flag collected by clap and dropped on the floor is invisible to every one of them. This drives <code>Args::parse_from</code> and asserts both directions — bare refuses the deployed https origin and still allows loopback; widened allows the named origin and not its neighbours. <strong>Injection:</strong> making <code>Args::origin_policy</code> return <code>OriginPolicy::default()</code> fails this test alone.</p>
<h3 id="two-origins-state-none-this-policy-can-answer-for"><a class="anchor" href="#two-origins-state-none-this-policy-can-answer-for">#</a>two_origins_state_none_this_policy_can_answer_for</h3>
<p>Found by review rather than by the ticket. <code>HeaderMap::get</code> is <strong>first-wins</strong>, and the deployment this README recommends puts a reverse proxy in front of the socket — a proxy that <em>appends</em> <code>Origin</code> rather than setting it leaves two, and reading the first lets the second be anything at all. No browser sends two, so two is not a state any policy can answer for and the handshake is refused. <strong>Injection:</strong> restoring the first-wins read (<code>(Some(value), Some(_))</code> answering from <code>value</code>) fails this test alone, across the whole workspace. The test also asserts the allowed origin <em>by itself</em> is still allowed, so the refusal is demonstrably the second header rather than the first.</p>
<h3 id="a-refusal-is-a-forbidden-response"><a class="anchor" href="#a-refusal-is-a-forbidden-response">#</a>a_refusal_is_a_forbidden_response</h3>
<p>The refusal has to be a refused <em>upgrade</em>, and the socket test asserts the <code>Error::Http</code> <strong>kind</strong> and the status rather than matching &quot;403&quot; in a <code>Display</code> string: a dropped connection and a refused handshake are different outcomes and only one of them is this fix. <strong>Injection:</strong> changing <code>forbidden()</code> to <code>UNAUTHORIZED</code> fails this and <code>a_handshake_from_a_foreign_origin_is_refused</code>, which is what shows the socket test reads the status rather than merely observing that connecting failed.</p>
<p>What the signature covers instead of a test: <code>transport::serve</code> <strong>takes</strong> the policy, so a host cannot forget to pass one — <code>doctrine(an_invariant_is_made_ unrepresentable_not_validated)</code>. What remains uncovered and is stated rather than guarded: a proxy that strips <code>Origin</code> turns the check off, which is why the proxy&#39;s own Origin allow-list stays in <a href="https://github.com/fiddlerwoaroof/improvise/blob/main/README.md"><code>README.md</code></a> as defence in depth.</p>
<h3 id="the-fixtures-basis-tracks-a-host-whose-layout-has-moved"><a class="anchor" href="#the-fixtures-basis-tracks-a-host-whose-layout-has-moved">#</a>the_fixtures_basis_tracks_a_host_whose_layout_has_moved</h3>
<p><a class="tid" href="../tickets.html#improvise-1l9o" title="furnished_client hand-builds a basis instead of taking the server&#39;s">improvise-1l9o</a>. <code>furnished_client</code> and <code>client_ready_to_edit</code> built their <code>BasisCache</code> by walking the client&#39;s own grid for <code>Record</code> cells and calling <code>basis::published(0, 0, records)</code> with every version hard-coded to zero — a small re-implementation of <code>project.rs::basis_update</code>&#39;s record selection, in a file whose entire purpose is to rule out re-implementations. Both now take the <code>Projection::Basis</code> out of a real <code>Sessions::connect</code> snapshot.</p>
<p>The reason the ticket calls it a nit is the reason it needed a test: a fresh <code>App</code> really is at generation 0, layout 0 and every record at version 0, so the fabrication and the publication are <strong>indistinguishable</strong> on every existing fixture. All 23 lockstep tests pass under either. So the change alone is structural and untested by construction, and this test is what makes it observable: it moves a tier first — a <code>HideItem</code> through <code>apply_on</code>, which <code>observe_reshape</code> sees as a shape change — and then asserts the fixture&#39;s claim names the host&#39;s current tiers.</p>
<p>Note <code>SetNumberFormat</code> does <strong>not</strong> work here and was the first attempt. It declares <code>EffectKind::Model(ModelChange::LayoutGeometry)</code>, but <code>layout_version</code> is <strong>observed</strong> from <code>view_shapes</code> rather than read off the declaration (<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>), and a number format is not part of a view&#39;s shape hash. A command being classified <code>LayoutGeometry</code> is therefore not a promise that it moves <code>layout_version</code> — which is the observed-not-declared design working as intended, and easy to misread as a bug.</p>
<p>Fault injected 2026-09-21: re-fabricating the basis inside <code>published_basis</code> (<code>published(0, 0, real_records)</code> — keeping the real record set, so only the two document tiers are forged) fails this test with <code>left: (0, 0)</code> against <code>right: (0, 1)</code>, and fails nothing else in the workspace.</p>
<p><strong>One residual, found in self-review and not closed.</strong> <code>published_basis</code> asks <code>Sessions::connect</code> for a window of <code>window.0 x window.1</code>, and a fresh session sits at offsets zero; <code>client_of</code> builds its grid at the <strong>cursor&#39;s</strong> offsets. Every fixture here has its cursor at the origin, so the two windows coincide and the published record set is the one the client&#39;s grid names. A future fixture that scrolls before building its client would get a basis naming records its grid does not show — which <code>claim</code> would then silently omit, and <code>Rejection::RecordUnstated</code> is what the server answers with. The fixtures no longer fabricate, which was the ticket; they do still assume a cursor at the origin.</p>
<h3 id="leaving-a-panel-in-records-mode-lands-where-the-host-lands"><a class="anchor" href="#leaving-a-panel-in-records-mode-lands-where-the-host-lands">#</a>leaving_a_panel_in_records_mode_lands_where_the_host_lands</h3>
<p><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>AppMode::Normal</code> is settled by the host against the layout the batch ends on; the wire carries <code>Command::ChangeMode(AppMode::Normal)</code> and <code>reduce_view</code> folds it verbatim. So a client that opened a panel from records mode and pressed Esc landed in <code>Normal</code> while its host landed in <code>RecordsNormal</code> — and <code>RecordsNormal</code> has its own keymap, so the divergence compounds on the next keypress.</p>
<p>The reachable path, which the ticket did not name: <code>Tab</code> in <code>RecordsNormal</code> resolves locally to <code>ChangeMode(CategoryPanel)</code>, and <code>Esc</code> there resolves to <code>ChangeMode(Normal)</code>. <code>c</code>/<code>f</code>/<code>v</code> are <code>Ignored</code> in <code>RecordsNormal</code> and <code>R</code> itself is a <code>stranding</code> decline, so the panel keys a first draft of this test used never separated anything — the test panicked on &quot;the client must answer this key itself&quot; rather than on the drift.</p>
<p><strong>Why lockstep could not see it before.</strong> Its records coverage (<code>typing_at</code>, <code>records_pair</code>) set <code>host.view_state.mode</code> by assignment and seeded the client with <code>client.view = app.view_state.clone()</code>, so it started where the mode and the layout already agree, and every divergence here requires them to disagree — <code>doctrine(a_guard_seeded_on_agreement_cannot_detect_drift)</code>. <code>in_lockstep</code> drives the key on both ends and then refreshes the client&#39;s grid from a <strong>real projection</strong>, never from the host&#39;s slice, which is the only seeding that can show the two apart.</p>
<p><strong>Fault injected</strong>: <code>resolve::changed_mode</code> reduced to <code>Command::ChangeMode(mode)</code>. The test fails on <code>close: the client and the host disagree about the mode</code>, <code>Normal</code> against <code>RecordsNormal</code>.</p>
<p>Two existing <code>resolve.rs</code> fixtures had the same shape and had to be corrected with the fix: <code>furnished</code> and <code>a_records_mode_commit_is_the_hosts</code> put the client in a records <em>mode</em> over a non-records <em>grid</em>, which is a state no host can produce. Both now project <code>GridUpdate::Records</code> as well. That they had to change is the evidence that the substitution was load-bearing rather than incidental.</p>
<h3 id="a-records-commit-is-declined-off-the-grid-and-not-off-the-mode"><a class="anchor" href="#a-records-commit-is-declined-off-the-grid-and-not-off-the-mode">#</a>a_records_commit_is_declined_off_the_grid_and_not_off_the_mode</h3>
<p><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>&#39;s damage half. <code>resolve::commit</code> is written to <strong>decline</strong> a records commit it cannot compose (it needs <code>record_col</code>, gotcha 1). It asked <code>state.view.mode.is_records()</code>; with the mode drifted to plain <code>Editing</code> over a records grid it did not decline, and composed <code>typed_cell_command</code> plus a <code>Basis</code> claim against a grid whose columns are <code>[coords…, +cat, measures…, +]</code>. A wrong write, strictly worse than the black hole it exists to produce.</p>
<p>The test states the drift rather than reaching it — reaching it takes the key sequence the test above drives — and asserts no <code>Command::Model</code> is composed.</p>
<p><strong>The control is the point.</strong> A first version of this test passed against the <em>broken</em> code, because <code>client_of</code> publishes no basis and <code>BasisCache::claim</code> returns <code>None</code> until one arrives, so <code>commit</code> returned <code>None</code> for a reason that had nothing to do with records mode: <code>doctrine(an_injection_that_does_not_fail_names_an_untested_path)</code>, caught by injecting the old spelling and watching the test still pass. <code>records_client</code> now publishes a basis naming every record the grid projects, and the test carries a positive control — the same <code>Editing</code> mode over a <strong>regular</strong> grid must still compose a write — so &quot;this client never writes&quot; cannot pass it.</p>
<p><strong>Fault injected</strong>: the decline restored to <code>state.view.mode.is_records()</code>. The test fails on <code>a records commit this client cannot compose was composed anyway</code>.</p>
<h3 id="the-normal-pair-is-settled-in-both-directions"><a class="anchor" href="#the-normal-pair-is-settled-in-both-directions">#</a>the_normal_pair_is_settled_in_both_directions</h3>
<p><code>App::settle_normal_mode</code> settles <strong>both</strong> <code>Normal</code> and <code>RecordsNormal</code> against the layout, so a <code>RecordsNormal</code> over an ordinary grid becomes <code>Normal</code>. The first version of the client&#39;s settling mapped only <code>Normal → RecordsNormal</code>, which left the mirror-image drift live. Found in review of this branch.</p>
<p>The test now aims at <code>improvise_protocol::settled_mode</code>, which is the <strong>one</strong> rule all three ends call — <code>ClientState::fold</code>, <code>ClientSession::fold</code> and <code>App::settle_normal_mode</code>.</p>
<p><strong>Fault injected</strong>: the <code>(Normal | RecordsNormal, false)</code> arm narrowed to <code>(Normal, false)</code>. The case <code>records=false, holding RecordsNormal</code> fails. The fourth row and the pass-through cases are the negative arms — without them a rule that answered <code>RecordsNormal</code> always, or <code>Normal</code> always, would pass, and the pass-through loop is what pins that <code>Editing</code>/<code>RecordsEditing</code> are <strong>not</strong> settled (the reason a records decision asks the grid and never the mode).</p>
<h3 id="switching-between-a-records-view-and-a-pivot-view-settles-both-ways"><a class="anchor" href="#switching-between-a-records-view-and-a-pivot-view-settles-both-ways">#</a>switching_between_a_records_view_and_a_pivot_view_settles_both_ways</h3>
<p><strong>The P1 ChatGPT Codex found on PR #98, and the more useful half is why the first fix missed it.</strong> That fix settled the mode at each <em>composition site</em> (<code>resolve::changed_mode</code>), against the grid the client held when it resolved the key. <code>switch_view_at</code> composes <code>[SwitchView(target), ChangeMode(Normal)]</code>, so it settled against the <strong>outgoing</strong> view: records → pivot emitted <code>RecordsNormal</code> and pivot → records emitted <code>Normal</code>, recreating the very drift the PR closed.</p>
<p>The hand-back had <em>predicted</em> this as follow-up 3 and concluded it was unreachable <strong>because <code>R</code> is a <code>stranding</code> decline in the browser</strong>. That reasoning is right about the toggle route and wrong about this one: records-ness is auto-detected from the axes (gotcha 1), so a <strong>view switch</strong> changes it with nothing in the batch saying &quot;records&quot;. One door was enumerated and there were two — <code>doctrine(an_unreachability_claim_is_only_as_wide_as_the_routes_enumerated)</code>. Codex had flagged the same path on PR #91, where the host side was fixed and the client side was not, so it was tracking one defect across three PRs.</p>
<p>The remedy is general rather than a patch to <code>switch_view_at</code>: <code>changed_mode</code> is deleted, every site emits the plain <code>AppMode::Normal</code>, and <code>ClientState::fold</code> settles after <strong>every</strong> fold against the grid it holds. Route-independent, so no enumeration of composition sites is load-bearing any more.</p>
<p><strong>The fixture drives the real path</strong>: a <code>Host</code> in records mode, a session connected (which opens the document&#39;s <em>default</em> view, gotcha 29 — the first draft asserted the opposite and failed its own precondition), the composed pair folded locally and sent as a <code>ClientFrame::Commands</code>, and every reply frame delivered in arrival order. <code>deliver</code> takes the whole reply rather than a hand-picked projection.</p>
<p><strong>The composed <code>ChangeMode</code> carries the STALE answer deliberately</strong> — <code>settled_mode(&amp;Normal, client.grid.records)</code> off the outgoing grid — because that is what <code>switch_view_at</code> really emitted, and composing the friendly value instead would make the records → pivot direction pass under the fault.</p>
<p><strong>Fault injected</strong>: <code>ClientState::settle_mode</code> reduced to a no-op. Both directions fail, each proved separately because the loop stops at the first:</p>
<ul>
<li>pivot → records: <code>_Records: the client settled against the view it LEFT</code>, <code>Normal</code> against <code>RecordsNormal</code>.</li>
<li>records → pivot, reached by relaxing the first iteration&#39;s expectation under the same injection: <code>Default: the client settled against the view it LEFT</code>, <code>RecordsNormal</code> against <code>Normal</code>.</li>
</ul>
<p>Records-ness and the mode are asserted as <strong>two separate facts</strong> rather than one derived from the other — a first version computed the records precondition from the mode expectation, so relaxing the mode silently relaxed the precondition too and the test failed for the wrong reason.</p>
<p><strong>It has a host-side twin, and the two are a pair rather than a duplicate.</strong> <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> landed <code>switching_views_across_records_ness_settles_against_the_view_switched_to</code> in <code>ui/app.rs</code> while this was being written: the same defect, the same batch (<code>[SwitchView(to), ChangeMode(Normal)]</code>), the same crossing in both directions, and independently the same phrase in its message — &quot;settled against the view it LEFT&quot;. One is the host settling after the batch against the layout it ends on; this one is a client settling after the fold against the grid it holds. Neither is redundant: nothing in the projection stream carries the host&#39;s <code>ViewState</code> (gotcha 47), so a host-side guard cannot see a client&#39;s mode and the reverse. Delete either and that side goes unwatched.</p>
<h3 id="the-mode-a-switch-lands-in-is-the-one-a-host-on-that-view-holds"><a class="anchor" href="#the-mode-a-switch-lands-in-is-the-one-a-host-on-that-view-holds">#</a>the_mode_a_switch_lands_in_is_the_one_a_host_on_that_view_holds</h3>
<p>The lockstep half of the entry above: it asserts the host itself answers <strong>differently</strong> per view (<code>Normal</code> on the pivot, <code>RecordsNormal</code> on the records view). Without it, &quot;the client settles to X&quot; is a claim about the client alone — a host that answered <code>Normal</code> everywhere would make the switch test&#39;s expectations arbitrary rather than lockstep.</p>
</main>
</div>
</body>
</html>