Merge pull request #142 from fiddlerwoaroof/docs/kb-session-facts
CI / Build all bundles (push) Has been cancelled
CI / Test and lint (host) (push) Has been cancelled
CI / wasm32 check (browser bundle crates) (push) Has been cancelled
CI / Browser JS unit tests (node --test) (push) Has been cancelled
CI / Knowledge base consistency (push) Has been cancelled
CI / Lint the harness crate (push) Has been cancelled
CI / Browser harness (OPFS contract + shell) (push) Has been cancelled
CI / Deploy to GitHub Pages (push) Has been cancelled

docs(kb): the session's rulings, deferrals and doctrine
This commit is contained in:
Ed Langley
2026-09-22 18:54:28 -07:00
committed by GitHub
3 changed files with 193 additions and 42 deletions
+74 -38
View File
File diff suppressed because one or more lines are too long
+6 -4
View File
@@ -194,11 +194,11 @@ Region=West, Measure=Revenue = |pending| # pipe-quoted text
26. **Axis presentation lives on `Axis`, colors resolve in `draw.rs`.** Four vocabularies hang off the enum in `view/axis.rs` — `plain_label()` ("Row"/"Col"/"Page"/"None"), `glyph()` (which `Display` appends, giving "Row ↕"), `short_label()` (three columns for the tile bar; `None` is "·", *not* a truncation), and `initial()` (one letter for the view panel; `None` is empty). Plus `color() -> AxisColor` and `next()` for the Row→Column→Page→None rotation. `AxisColor` is a semantic palette: `improvise-core` must not name a ratatui type, so `draw.rs::axis_fg` does the one mapping to a terminal color, exactly as `minibuf_fg` does for `MinibufColor` (improvise-rml). The tile bar and category panel each carried their own copy of the color table before, with nothing keeping them in agreement. Not covered here: `persistence/format.rs` maps `Axis` to the on-disk keywords ("row"/"column"/"page"), which is a format concern and stays in the io layer.
27. **`Axis::Filter` constrains without being navigable.** Drilling fixes its coordinates on `Filter`, not `Page` (improvise-dz8): both restrict which records are visible, but only `Page` is something the user may move or page through. The split shows up as two different questions, and picking the wrong one is the trap: **"what data is visible" is `View::filtering_cats()`** (Page ∪ Filter — used by `GridLayout` and by `add-record-row` when it builds a key from the current constraints), while **"what can the user act on" is `categories_on(Axis::Page)`** (used by `[`/`]` page cycling) or `View::tileable_cats()` (used by all three tile-select sites). `Axis::next()` maps `Filter` to itself and no navigable axis rotates into it, so tile select cannot hand one out. The `.improv` grammar accepts `filter` and round-trips a selection with it — a Filter axis only ever occurs in the never-persisted `_Drill` view, but the alternative was writing it out as some other axis.
27. **`Axis::Filter` constrains without being navigable.** Drilling fixes its coordinates on `Filter`, not `Page` (improvise-dz8): both restrict which records are visible, but only `Page` is something the user may move or page through. The split shows up as two different questions, and picking the wrong one is the trap: **"what data is visible" is `View::filtering_cats()`** (Page ∪ Filter — used by `GridLayout` and by `add-record-row` when it builds a key from the current constraints), while **"what can the user act on" is `categories_on(Axis::Page)`** (used by `[`/`]` page cycling) or `View::tileable_cats()` (used by all three tile-select sites). `Axis::next()` maps `Filter` to itself and no navigable axis rotates into it, so tile select cannot hand one out. The `.improv` grammar accepts `filter` and round-trips a selection with it — a Filter axis only ever occurs in the never-persisted `_Drill` view, but the alternative was writing it out as some other axis. **What a virtual view must *be* is model-side and has one home** (improvise-gd28): `view::{records_spine, records_steps, drill_steps}` return `Vec<ViewStep>` — pure data naming no `Effect`, no `App` and no front end — and `cmd/grid.rs::steps_into_active_view` is the whole of *how the TUI gets there*, twenty lines a browser answers its own way. Before the split, the drill's filter placement and the records spine were spelled out twice in `cmd/grid.rs` with nothing keeping them in agreement. `DrillState` deliberately did **not** move: it holds an `Rc` and its `pending_edits` are uncommitted writes, so gotcha 30's session-local tag already places it. Reasoning: `context/code/core.md` §11.
28. **The browser backend is sync on purpose, with an async seam.** `OpfsFs` (`improvise-io/src/opfs.rs`, wasm32 only) implements the same synchronous `Storage` trait as `PhysicalFs`, because `FileSystemFileHandle.createSyncAccessHandle()` yields a handle whose read/write/truncate/flush **are** blocking — available only inside a dedicated Web Worker, which is where improvise-9x6 puts the app. So `Storage` never became async. What is still async splits out into four explicit methods the worker awaits from its message loop: `open()`, `attach(path)` (must precede any read/write of that document — acquiring a handle is async even though using it is not), `refresh()` (repopulates the index `list` answers from), and `commit_deletes()`. Consequence: **`delete` is visible immediately but lands in OPFS at the end of the batch** — it drops the name from the index and queues a `removeEntry`. Handles are exclusive per file, so `attach` closes any handle it replaces and `detach` releases the lock. `storage_contract` in `storage.rs` is the behaviour every backend owes; it runs against both host backends and is the written-down spec `OpfsFs` must satisfy in a browser, where the harness cannot reach. **And the premise is dedicated-worker-only, which is narrower than this paragraph used to read.** `createSyncAccessHandle` is spec'd `[Exposed=DedicatedWorker]`, and measured in a module `SharedWorker` on Chromium and Firefox it is **absent** — not rejected, not flagged, not transient: `getDirectory()` and `getFileHandle()` both succeed and the method is simply not on the object (improvise-12ha). So `OpfsFs` cannot be hosted by a `SharedWorker` as written, and "a browser worker" in this paragraph means a *dedicated* one everywhere it appears. `OpfsFs::open`'s own guard does not enforce that — it `dyn_into`s `WorkerGlobalScope`, which a `SharedWorkerGlobalScope` **satisfies**, so a shared worker sails past a check whose message names the right requirement and fails later as a raw `TypeError` deep in the glue (improvise-7gip). The consequence for multi-tab is that the machinery everyone reaches for first is the storage seam, not `Sessions`: `Sessions`, the `Addressed` filter and the one `frame` pair are all ready, and none of that helps, because the no is entirely on the handle.
29. **"Which view is active" is stored twice, and the copies are now allowed to differ.** `Workbook::active_view` is the document's **default** — what a freshly connected client opens, and what `.improv` writes as `Initial View`. `ViewState::active_view` is **authoritative for that client**, and it is what keys `ViewState::cursors`. The second copy exists because `reduce_view` runs on a client that holds no `Workbook` (improvise-cqi); the *split* is improvise-jytm answering improvise-g0f, on the user's ruling that **the model is shared and the UI is not**. Two clients may sit on different views of one document. Three things follow. **`App::resolve_view` is the one answer** to what a view name names in this document, falling back to the default so a slice naming a deleted view cannot panic in `rebuild_layout`; `view()`, `view_mut()`, `view_name()`, `project::grid_in` and `ClientSession::view_in` all go through it, and a second copy of that fallback was the first thing review found. **The guard was replaced, not deleted** — `the_two_active_view_names_never_diverge` became `the_documents_default_view_outlives_this_clients_switches` plus `a_fresh_session_opens_the_documents_default_view`, because a deliberate split with no guard is how one silently regresses into the other. And **a deleted view is the case the split creates**: `DeleteView` can take away the view another client is on, so `Sessions::adopt_deleted_views` moves that mirror to the default and tells it, and `effect::DeleteView` does the same for the host — observed ("does this mirror still name a view the document has"), never predicted from the command variant. **Testing any of it needs a bystander that can be moved**: both audience tests first sat theirs on the default, where a wrongly-addressed relay folds to a no-op, and review demonstrated the vacuity by injecting `ToAll` twice. Two gaps remain, both filed: a view-scoped `ModelCommand` names no view, so a connected client's `SetAxis` lands on the host's view (improvise-lltt — deferred on a *condition*, that no shipped client emits `SwitchView`, while the browser already emits `TransposeAxes`), and `Initial View` no longer follows the TUI user (improvise-azhb). Follow mode is deliberately not foreclosed.
29. **"Which view is active" is stored twice, and the copies are now allowed to differ.** `Workbook::active_view` is the document's **default** — what a freshly connected client opens, and what `.improv` writes as `Initial View`. `ViewState::active_view` is **authoritative for that client**, and it is what keys `ViewState::cursors`. The second copy exists because `reduce_view` runs on a client that holds no `Workbook` (improvise-cqi); the *split* is improvise-jytm answering improvise-g0f, on the user's ruling that **the model is shared and the UI is not**. Two clients may sit on different views of one document. Three things follow. **`App::resolve_view` is the one answer** to what a view name names in this document, falling back to the default so a slice naming a deleted view cannot panic in `rebuild_layout`; `view()`, `view_mut()`, `view_name()`, `project::grid_in` and `ClientSession::view_in` all go through it, and a second copy of that fallback was the first thing review found. **The guard was replaced, not deleted** — `the_two_active_view_names_never_diverge` became `the_documents_default_view_outlives_this_clients_switches` plus `a_fresh_session_opens_the_documents_default_view`, because a deliberate split with no guard is how one silently regresses into the other. And **a deleted view is the case the split creates**: `DeleteView` can take away the view another client is on, so `Sessions::adopt_deleted_views` moves that mirror to the default and tells it, and `effect::DeleteView` does the same for the host — observed ("does this mirror still name a view the document has"), never predicted from the command variant. **Testing any of it needs a bystander that can be moved**: both audience tests first sat theirs on the default, where a wrongly-addressed relay folds to a no-op, and review demonstrated the vacuity by injecting `ToAll` twice. One gap remains: `Initial View` no longer follows the TUI user (improvise-azhb). The other — a view-scoped `ModelCommand` naming no view — is **closed** (improvise-lltt), and this paragraph described it as an open gap for a day after: every view-scoped variant carries `view: String` and `model_effects` maps it to `ViewTarget::Named`, so a connected client's `SetAxis` lands on the view it names. `ViewTarget::Active` resolves through `App::view_mut()` and is therefore the *host's* view — but **the wire never constructs one**, which is what closed it. Follow mode is deliberately not foreclosed.
30. **Two tests gate `ViewState` membership.** `ViewState` is the *portable* session slice — the one thing serialized into the Snapshot a newly-connected client gets (improvise-q08). A field may live there only if it is **portable** (plain data from the allowlisted crates, no `Rc`/`Arc`) and **snapshot-safe** (a fresh client inheriting the value is correct). `wizard` fails both — `ImportWizard` is an `improvise-io` type, and a half-open dialog is not something to inherit — and `drill_state` fails both — it holds an `Rc`, and `pending_edits` are uncommitted writes belonging to whoever staged them. Both live on `App` under the **session-local** residue tag (improvise-h0b). The `ViewState: Send` static assertion in `app.rs`'s tests is the compile-time proxy for portability: `Rc` is not `Send`, so a re-added shared pointer fails the build (the `Deserialize` derive from gotcha 25 now rejects it too, serde's `rc` feature being off). `Send` does *not* catch the origin-crate or snapshot-safety halves — check those by hand.
@@ -221,7 +221,7 @@ Region=West, Measure=Revenue = |pending| # pipe-quoted text
39. **`Applied` is an enum because a document swap is not a diff.** `Sessions::apply_checked` returns `Applied::Diff { dirty, broadcast }`, `Applied::Rebased { snapshots }` or — since ADR 001 layer 1 — `Applied::Rejected { reason }`, and the enum *is* the mechanism: a host that wants the `Dirty` must match, and matching names the case where a `Snapshot` is owed. The server's mirrors self-heal — `ClientSession::project` reseeds on the `document_generation` bump — but `ViewState::cursors` lives on the **client**, keyed by the outgoing document's view names (gotcha 24), and only a fresh `Snapshot` replaces it. `Command::Model` put the three `WholeWorkbook` mutations on the wire (gotcha 38), so the host that must send the snapshots is no longer the host that asked. `apply_model` therefore calls `rebase` itself rather than instructing the host to, exactly as `apply_switch` adopts the accepted view itself. **The trigger is the generation moving, not the command variant**: a `LoadModel` that cannot read its path leaves the document alone, and reseeding on the variant would cost a client its cursors for a *failed* load — `a_load_that_fails_is_not_a_rebase` guards that arm. A `rebase: bool` beside `dirty` was rejected, because the host that forgets to call `rebase` is the host that forgets to read the flag. The **third** case is a *normal outcome*, not an error, and it is in the enum for the same reason the other two are: a host that wants the `Dirty` must match, and matching names it. `ServerMsg::Rejected` is the matching frame, addressed to **the asker alone** — nothing happened, so nobody else is owed anything — and the client keeps it as a `Rejection` rather than a sentence, because force needs to know *which* record and *which* kind (gotcha 43).
**Fixture warning.** When testing a document swap, the incoming document's active view must have the **same name** as the outgoing one. A rename makes `apply_model` broadcast a `SwitchView`; the client folds it, and the stale cursor is then read under a key nothing has written — right *by accident*, with the bug invisible. `other_document()` renames, and a first version of this test passed because of it; `a_document_shaped_differently_under_the_same_view_name()` keeps the name and changes only the shape, so there is nothing to hide behind.
40. **A batch's basis is three tiers, and only two of them can be read off the effect.** `App` carries `document_generation` (bumped by `install_workbook`), `layout_version` (per `ModelChange::LayoutGeometry` effect) and `record_versions` (per *record*) — ADR 001's stale-basis check, and shared document state, so never on `ViewState` (gotcha 30). **The unit is the record**: `record_of(key)` is the coordinate tuple minus `_Measure` (`record_row_identity`), because renaming a category coordinate in wide records mode moves every measure cell in the row (gotcha 1) — a cell-level unit is violated by one legal edit, so the unit must be at least as coarse as the coarsest legal write. Both tiers are read in `apply_effects` off the same `EffectKind` the `Dirty` is: one classification, two readers, no second table to drift from gotcha 35's. `layout_version` bumps **inside** the loop for the reason `Dirty` accumulates there — an aborted batch must not claim effects it never ran (gotcha 21). `record_versions` **cannot**, because an `EffectKind` says *that* a batch writes cells, never *which*; so the records are **observed**, via a per-record XOR fingerprint of the raw data taken when the batch declares a `CellData` effect and diffed after it. Three consequences. A write landing the value already there **bumps nothing**, which is correct — no basis was invalidated. A `LayoutGeometry` effect that drops cells **will** bump their records when it shares a batch with a `CellData` one: an over-bump costing at most an unnecessary rejection, the same direction `Dirty::of` is deliberately sloppy in. And only *raw* data is fingerprinted — formula results live in `Model::formula_cache`, and a record whose derived value moved because someone edited elsewhere has not itself been written. The diff collects into a **set**: chaining `before.keys()` with `after.keys()` counts any rewritten record twice, a bug that survived one test run.
40. **A batch's basis is three tiers, and only two of them can be read off the effect.** `App` carries `document_generation` (bumped by `install_workbook`), `layout_version` (per *view shape*) and `record_versions` (per *record*) — ADR 001's stale-basis check, and shared document state, so never on `ViewState` (gotcha 30). **The unit is the record**: `record_of(key)` is the coordinate tuple minus `_Measure` (`record_row_identity`), because renaming a category coordinate in wide records mode moves every measure cell in the row (gotcha 1) — a cell-level unit is violated by one legal edit, so the unit must be at least as coarse as the coarsest legal write. **Only the `Dirty` is read off the `EffectKind`; both lower tiers are *observed* around the loop** — the source says so at `apply_effects` and this paragraph said the opposite until improvise-ilvl's audit. The `EffectKind` decides only *whether to look*: `touches_model` arms the shape snapshot, `writes_cells` the record fingerprints. `record_versions` **must** be observed, because an `EffectKind` says *that* a batch writes cells, never *which*; `layout_version` is observed for a better reason than necessity — `observe_reshape` diffs `view_shapes` before and after and bumps only where a view's shape actually moved (improvise-pdvm/nynk), so it is a **fact about the document** rather than a claim about the batch. Two consequences the declared form did not have. An **idempotent** `set-axis` bumps nothing, and batching two real changes into one line or two gives the same `2`. And the gotcha 21 argument is subsumed rather than restated: an aborted batch moved no shape, so there is nothing to not-claim — which is why observing once **after** the loop is correct where accumulating `Dirty` inside it is. Note the arming is deliberately wider than the tier: a `CellData`-only batch that prunes a row out of a view **does** bump `layout_version`, because the shape moved. Three further consequences. A write landing the value already there **bumps nothing**, which is correct — no basis was invalidated. A `LayoutGeometry` effect that drops cells **will** bump their records when it shares a batch with a `CellData` one: an over-bump costing at most an unnecessary rejection, the same direction `Dirty::of` is deliberately sloppy in. And only *raw* data is fingerprinted — formula results live in `Model::formula_cache`, and a record whose derived value moved because someone edited elsewhere has not itself been written. The diff collects into a **set**: chaining `before.keys()` with `after.keys()` counts any rewritten record twice, a bug that survived one test run.
41. **`Storage` is not a VFS, and that closes the SQLite question.** The trait is whole-document `read`/`write` bytes (gotcha 2, `invariant(storage_is_two_ops)`) — no offsets, seeking, handles or locking — so an `sqlite3_vfs`, which needs `xRead(offset,len)`/`xWrite`/`xTruncate`/`xLock`, has nothing to bind to. SQLite *can* still sit behind it, as an in-memory database serialized to a blob (`rusqlite` `serialize`/`deserialize_read_exact`, proven with a probe before declining — declining on a capability that actually exists would have been the wrong decline). It is worth nothing: measured on 18,000 cells the SQLite container is **22% larger** than `.improv`, **4.7× larger gzipped**, ~**160×** larger at the floor (24 KB against ~150 bytes), and still rewritten whole on every save, so the one dimension SQLite normally wins — incremental page writes — is unavailable. improvise-9ix was declined on that evidence. Two things generalise past SQLite. **A second serialization format is a second implementation of the grammar**, obliged to track `improv.pest` forever; and **its own round-trip test cannot detect drift from `.improv`**, because it never compares the two — that missing cross-format equality test is what made the ticket look cheap. If a structured-query story is ever wanted it is a one-way `export_sqlite` beside `export_csv`, native-only and feature-gated: `libsqlite3-sys`' C build fails under this repo's nix cc-wrapper for `wasm32-unknown-unknown`, which would break the `wasm` CI gate the day it landed.
@@ -237,6 +237,8 @@ Region=West, Measure=Revenue = |pending| # pipe-quoted text
44. **The TUI grid draws from its cache, and since improvise-anr9 the cache frames it too.** `GridWidget` holds no `&Model` and no `&View`: cell text, aggregate-ness (`CellId::Aggregate`, ADR 002), column widths, the page bar, the totals strip and now the **frame** all come off `GridCache`. The TUI still builds that cache over the **whole** grid (offsets zero), so a cache position is an absolute position — the browser's `Viewport` is a real window, the TUI's is not, and the two clients therefore use it differently; column totals **must** be projected rather than client-derived, because the sum runs over every row *including the ones a viewport scrolled past*. The frame is an `AxisFrame` per axis — `cats` (the categories, outermost first) plus `entries: Vec<FrameEntry>` interleaving `Group(FrameGroup)` headers with `Item(Vec<String>)` label *tuples*, exactly as `GridLayout::row_items` interleaves them. That replaced a `Vec<String>` built by `GridLayout::row_label`, which slash-joined the tuple — the exact rendering `two_row_categories_produce_cross_product_labels` says must not appear — so the browser drew `Food/Alice` where the TUI drew two header columns, with no groups and no collapse indicators. It is **one** type and not a `labels` plus a `levels` beside it: two answers to "what are the labels" is the disease (improvise-edp), and this is the second cure, which cost a *type* change rather than an addition. Three things to know before touching it. **`leaf_labels()` is not "the labels"** — it is the innermost label, what a one-line summary calls a row by; a draw loop wants `header_labels(first_rendered)`, `groups()` and `shares_ancestry(sel)`, each of which is one pass, because `item`/`label`/`shows_level`/`group_of` scan `entries` from the start and asking them per position is quadratic in the frame *and grows with the TUI's absolute scroll offset* (found three times on anr9; the per-position forms survive for one-off lookups and say so). **The width rule has one home**, `improvise_protocol::header_level_widths` — widest label per level, plus a gap, clamped — reached by `AxisFrame::header_widths` and by `ui::viewport::compute_row_header_width`, which borrows level tuples off a `GridLayout` rather than building a frame on the keypress path. **A `cats` change re-identifies every position**: a cell's identity is `cats` zipped with its tuple, so `invalidate_relabelled` blanks the whole axis when the categories move, not merely the positions whose tuples differ, and blanks the **totals** on a recategorization of *either* axis — a column total is a sum over the rows, so re-identifying the rows leaves a number answering coordinates the grid no longer has (gotcha 33(b): the rule lives in `apply`, not in a producer's memory). What is still **not** on the cache is `record_col`, and that is correct: it keys pending drill edits and is session-local (gotcha 1). `body_from(idx)` takes an argument only because the two front ends scroll differently — a browser's frame is already windowed and passes `0`, the TUI's covers the whole grid and passes its row offset — not because the rule differs. `group_of` answers group **membership**: the nearest preceding header *unless* an `AxisEntry::GroupEnd` closed the run first (improvise-gd83, landed in PR #77). It previously answered "nearest preceding header" unconditionally, which gave an ungrouped item the preceding group. `GroupEnd` draws nothing and exists only so membership is not inherited. Both of the gaps this sentence used to list are **closed**, and it named them as open for a day after: `group_runs` coalesces by **header identity** (`std::ptr::eq`), not by name, so two runs under two headers equal as values are two runs (improvise-h0iv); and `AxisFrame::first` carries **no** `serde(default)` — its doc comment says why, since zero is a claim and not an absence (improvise-1wl0). `doctrine(a_stale_list_of_what_is_missing_is_worse_than_no_list)` is about exactly this paragraph, twice now. And `project::grid_of` evaluates every cell per frame where the widget evaluated only the visible ones — a widening of an existing whole-grid cost. Since improvise-jytm `grid_in` may additionally **derive a `GridLayout`** for a session on a view the host is not on; the host's layout is reused where the names agree, which is the common case and the only one that can carry a drill snapshot.
**`RecordHash` is document-scoped; `AggregateHash` is layout-scoped and cannot be otherwise** (improvise-gd28). A `RecordHash` falls back to `DataStore`'s maintained index and so resolves against the **document**, which is why a connected client's `SetCell` works from any view. An aggregate has no existence outside the layout that derives it, so there is no index to fall back to and none can be built — `App::resolve_aggregate` therefore answers only for the **host's** active view, which since improvise-jytm is not the client's. **That, and not ADR 001 layer 2, is what blocks browser drill.** The ticket's blocking argument was right about the *commit* (`ApplyAndClearDrill` is addressed to one session, gotcha 38) and wrong about the *entry*: entering a drill needs no commit, and `CreateView`/`SetAxis`/`SetPageSelection` are already unaddressed `ModelCommand`s that name their view. The gap is one level down from where it was looked for — **the mutation and its operand have different scopes**: the mutation configures the shared `_Drill` and is addressed to nobody, while the operand exists only inside one session's layout. So the fix is not a new mechanism but the `view: String` convention every other view-scoped variant already follows, resolved against a layout derived for that name (improvise-obtu). Two things travel with it: `improvise-fdk1` is **not** needed, because `edit_or_drill` declines records-mode editing out loud before reaching a cell, so a browser cannot edit inside a drill grid at all; and closing it makes improvise-t219 live, `_Drill` being one shared view in `Workbook::views` that two clients drilling would contend for — the same masking relationship improvise-6uva has with improvise-up8g. Guarded by `an_aggregate_a_session_was_shown_does_not_resolve_against_the_hosts_view`, whose **control** is the load-bearing half — the same surrogate resolves once the host is moved onto that view, so a blanket `None` cannot pass.
45. **One session loop, two transports — and the browser saves itself.** `Host` is `App` + `Sessions` + `handle(id, ClientFrame) -> Vec<Addressed>` and lives in `improvise_command::ui::host`, **not** in `improvise-ws-server`, which re-exports it. It moved for improvise-9x6: the worker hosts the same authoritative `App` over a `MessagePort` and cannot depend on tokio, and a second copy would have been exactly the mirror improvise-xks6 had just deleted for the frames — a drifting `Applied`/`Broadcast` match is invisible until a client sees it. `registry` stayed on the server, because a worker hosts **one** document. What the worker adds is only what a `MessagePort` differs in: **one client by construction** (the `Addressed` filter is honoured anyway, against a future `SharedWorker`), and **a handshake with no close** — `ClientFrame::Viewport` must still come first, but a pre-handshake frame is *dropped* rather than closing a connection that does not exist.
**Saving is a frame, and dirtiness is read off the classification the effect already carries.** `effect::Save` is `ViewOnly` (gotcha 3) — correctly, since it writes *this host's* `file_path` through *this host's* `Storage` — so the derivation that builds `ModelCommand` from the model effects cannot name it, and for a while that meant no client on any transport could ask its host to save. `ClientFrame::Save` closes it (improvise-368h): a **request** carrying no path, because a client cannot see the host's storage, answered `ServerFrame::Rejected(NoDocumentPath | SaveFailed)` to the asker alone and **silently on success** — nothing moved, so no widget is dirty. It is a frame and not a `Command` for one concrete reason: as a `Command` it put a runtime-fallible operation inside a batch `ClientFrame::Commands` promises is all-or-nothing, so `[Save, SetCell]` reported a failed write and then applied the edit. It also fails the test every `Command` passes — it mutates no shared state and no client's slice, so `reduce_view` has nothing to fold and `Checked` has no basis to answer for. **And `MarkDirty` is gone.** It was a separate effect the *command* layer composed beside each mutation — reasonable on the key path, unreachable from anywhere else, because improvise-kmgs derived `ModelCommand` from the effects **one at a time** and so put `SetCell` on the wire without its companion (improvise-gv9r): a derivation correct per element and wrong for a set, whose symptom was that "save if dirty" never fired. `App::apply_effects` now raises the flag for any `EffectKind::Model(_)`, all three `ModelChange` levels included, which makes omitting it unrepresentable rather than validated. It over-approximates in the same direction `layout_version` does — a `LoadModel` that cannot read its path leaves the flag raised, costing one redundant write — and `Save` clears it in loop order, so a batch that edits and then saves ends clean. The worker's persist trigger is therefore the **flag**, not a table of command variants: an observation rather than a second description of which commands change the document, for the reason `cursor_epoch` beats matching on `CycleAxis` (gotcha 24) and `document_generation` beats matching on `LoadModel` (gotcha 39). It is also strictly tighter — a refused batch raises nothing, so the "was anything rejected" guard went with it. A browser still never *sends* `ClientFrame::Save`, and that is the answer rather than a gap: there is no file the user owns and no Ctrl-S semantics, the document *is* the OPFS entry, so the worker persists on its own behalf through the one existing `Save` effect. The cost that remains: `Storage` is whole-document (gotcha 41), so OPFS rewrites the file per accepted model command.
@@ -251,7 +253,7 @@ Region=West, Measure=Revenue = |pending| # pipe-quoted text
47. **Keys become commands in the browser, and the server must not know the keymap.** The user's ruling on improvise-bpq2, following from "the model is shared, the UI is not" — and it buys two clients different bindings over one document. What made it urgent is **not** latency. `a_key_resolved_on_the_host_moves_state_no_client_can_hear` shows nothing in the projection stream carries the host's own `ViewState`, so host-side resolution **diverges**; and `Host::handle` answers `ClientFrame::Key` with an explicit empty vec, so **an upstream key is a black hole, not a slow path** — every `KeyOutcome::Upstream` names a binding that does nothing at all in a browser. That test still holds and was not flipped: bpq2 closes the divergence by *avoiding* it. **The method is the ticket's** — enumerate the **bindings**, not the commands (`doctrine(binding_dump_diff)`) — and where a client cannot compose the `Command`, **enrich the projection** rather than moving resolution back.
Three things that cost. **`GridExtent` on `GridCache`** — the whole grid, of which the labels are one window — because a viewport-indexed cache cannot tell "the last visible row" from "the last row". It is deliberately **not framed by the labels** (`fit_to_labels` leaves it alone) and its batch position is not load-bearing the way `RowFrame`-before-`Cells` is, since it addresses no position; the producer reads `GridLayout::row_count()`, and reading it off the projected ranges is **invisible in the TUI** (which builds at offsets zero, gotcha 44) and is exactly the browser bug. **The window is the client's own fact**, recorded by `Client::set_viewport` from what it announced — *not derivable*, since the row frame's own length is the window **clipped to the grid** and so differs precisely at the bottom edge where the scroll arithmetic matters. `Client::viewport` (associated, no recording) still exists and is the one that quietly turns cursor keys off; `web/app.js` does call `set_viewport`. **`ClientFrame::Commands(Vec<Command>)`** replaces the singleton, because one cursor key is `SetSelected` plus the offsets `viewport_effects` returns and a host that folds the selection but not the offset projects the wrong window. It is **answered whole, then applied whole** (improvise-955n): `CheckedBatch::against` checks the frame before anything runs, so there is no longer a moment at which part of a frame has landed and the rest has been refused, and `Applied::Rejected` is unreachable through `Host::commands` — still matched, because a host that wants the `Dirty` must name every case (gotcha 39). The contract is four clauses and **clause 4 is not delivered**: one basis per frame, checked once, applied in order with each command seeing the last one's state, and all-or-nothing. The fourth is open because `App::apply_effects` aborts without undoing (gotcha 21 — an abort is not a rollback), so `[RemoveItem("North"), SetCell(the North record)]` lands the removal, resolves the write to nothing, and reports the frame accepted having applied half of itself — partial *and* silent. It is unreachable from any client in this workspace, the only multi-command producer being `keys::commit`; **reachability is a reason not to rush a fix and not a reason not to notice one** — the lost update `CheckedBatch` fixed had the same reachability argument and none of the documentation, which is how it shipped.
Three things that cost. **`GridExtent` on `GridCache`** — the whole grid, of which the labels are one window — because a viewport-indexed cache cannot tell "the last visible row" from "the last row". It is deliberately **not framed by the labels** (`fit_to_labels` leaves it alone) and its batch position is not load-bearing the way `RowFrame`-before-`Cells` is, since it addresses no position; the producer reads `GridLayout::row_count()`, and reading it off the projected ranges is **invisible in the TUI** (which builds at offsets zero, gotcha 44) and is exactly the browser bug. **The window is the client's own fact**, recorded by `Client::set_viewport` from what it announced — *not derivable*, since the row frame's own length is the window **clipped to the grid** and so differs precisely at the bottom edge where the scroll arithmetic matters. `Client::viewport` (associated, no recording) still exists and is the one that quietly turns cursor keys off; `web/app.js` does call `set_viewport`. **`ClientFrame::Commands(Vec<Command>)`** replaces the singleton, because one cursor key is `SetSelected` plus the offsets `viewport_effects` returns and a host that folds the selection but not the offset projects the wrong window. It is **answered whole, then applied whole** (improvise-955n): `CheckedBatch::against` checks the frame before anything runs, so there is no longer a moment at which part of a frame has landed and the rest has been refused, and `Applied::Rejected` is unreachable through `Host::commands` — still matched, because a host that wants the `Dirty` must name every case (gotcha 39). The contract is four clauses and **all four are now delivered** — clause 4 by improvise-yqpd on 2026-09-21, whose mechanism is `App::snapshot`/`restore` plus a `Sessions` clone taken in `Host::mutating_frame` and put back by `undo_frame`, deliberately *not* inside `apply_effects`, which runs on every keypress. **This paragraph said it was undelivered for a day after it landed**, and an orchestrator brief repeated that from here, so read the clause list as a claim to check. A delivered clause 4 is also what makes the CLIENT's rollback well-defined (improvise-nxah): a refusal means the host applied none of the frame and restored every mirror, so a client keeping its optimistic fold is the only thing left out of step. What rolls back there is `ViewState`, never a cache — a `Command::Model` is a no-op in `reduce_view`, so the optimistic fold never reached one. The old text, kept because the hazard it names is still the right shape to fear: one basis per frame, checked once, applied in order with each command seeing the last one's state, and all-or-nothing. was that the fourth was open because `App::apply_effects` aborts without undoing (gotcha 21 — an abort is not a rollback), so `[RemoveItem("North"), SetCell(the North record)]` would land the removal, resolve the write to nothing, and report the frame accepted having applied half of itself — partial *and* silent. That is what `yqpd` closed, and it closed it **outside** `apply_effects` for the reason the old text implies: the abort lives on the keypress path and must stay cheap. The old note that it was *unreachable* from any client here, the only multi-command producer being `keys::commit`, still reads correctly as a caution — **reachability is a reason not to rush a fix and not a reason not to notice one**, the lost update `CheckedBatch` fixed having had the same reachability argument and none of the documentation, which is how it shipped.
Two corrections to the ticket's own gap list, both worth more than the code. **Panel cursor movement was never a gap**: improvise-edp had already made `viewmodel::flatten_cat_tree` the one home both ends call, so it is the *shared* derivation, not a second one — worth re-checking the "known gaps" in other old tickets against what has landed since. And the "unmodified characters only" rule was **mis-scoped, not wrong**: it was about `KeyPattern::AnyChar`, which is bound only in the text-entry modes, so `Ctrl-d` in Normal has an exact binding and mirroring it is a faithful subset. Each resolver mirrors its `Cmd` **line for line, including where siblings disagree** — `Move` and `MoveTileCursor` emit even when they clamp, `MovePanelCursor` returns nothing when the cursor does not move. Tidying that into one rule would make the client a second derivation, which is what `tests/lockstep.rs` exists to rule out. What still needs the host is a **named list** in `keys.rs` and in `the_normal_mode_keys_that_still_need_the_host_are_these`, so closing one has to come there and say so.
+113
View File
@@ -52,6 +52,7 @@ location(wasm_ci,'.github/workflows/ci.yml','wasm job').
location(view_state_membership,'crates/improvise-command/src/ui/app.rs','ViewState doc comment states both membership tests; App carries the session-local residue tag').
location(axis_presentation,'crates/improvise-core/src/view/axis.rs','plain_label/glyph/short_label/initial/color/next on Axis; AxisColor semantic palette').
location(axis_color_resolution,'src/draw.rs','axis_fg maps AxisColor to a ratatui Color, beside minibuf_fg which does the same for MinibufColor').
location(tui_truncate,'src/ui/text.rs',truncate).
invariant(cellkey_sorted,'CellKey coords always sorted by category; build via CellKey::new/with/without').
invariant(display_rounding,'format_f64 is render-only; never feed rounded values back into eval').
invariant(fixedpoint_eval,'recompute_formulas iterates to stability; circular -> CellValue::Error(\"circular\")').
@@ -124,6 +125,18 @@ invariant(normal_mode_is_resolved_not_carried,'AppMode::Normal is resolved throu
invariant(the_frame_basis_follows_active_view,'The cursor frame basis re-bases AT THE SWITCH, in the loop, as soon as active_view moves -- not at the next geometry effect. Guarding the COMPARISON on the two names agreeing (PR #78 round 2) exempted the first geometry effect after a mid-batch SwitchView from the check altogether, which is under-reporting and gotcha 35 expensive side. SwitchView stays ViewOnly: the basis is App own bookkeeping and none of it crosses the wire, so this needed no protocol change. Costs one extra view_frame per batch that both switches and moves geometry, DrillIntoCell paying most.').
invariant(encoded_width_mirrors_every_preprocess_branch,'The preflight runs BEFORE preprocess and the exact check AFTER it, so the exact check bounds the error message and not the allocation. encoded_width must mirror EVERY arm of preprocess inference -- Numeric, Date (5 parts, 7 with a time) and Categorical (distinct plus the reserved (missing)) -- plus the .missing flag. It may over-count; it must never under-count. improvise-x1ns was TWO under-counts one inference-branch apart: the categorical (missing) one-hot, and the entire Date arm. The guard is that encoded_width matches Forced WITH NO WILDCARD, so a fourth variant is a compile error rather than a silent categorical fallback -- which is exactly how Date was missed.').
invariant(opfs_sync_handles_are_dedicated_worker_only,'createSyncAccessHandle is spec-ed [Exposed=DedicatedWorker] and is ABSENT -- not rejected, not flagged, not transient -- on a file handle obtained inside a module SharedWorker. Measured 2026-09-21 on Chromium and Firefox: navigator.storage.getDirectory() and getFileHandle() both SUCCEED there and the method is simply not on the object; the real OpfsFs bundle, run unmodified in a shared worker, fails at exactly that call. Playwright WebKit has no OPFS in any context, so it is out one step earlier. Consequence: gotcha 28-s premise -- the handle-s read/write/truncate/flush ARE blocking, which is the whole reason Storage stayed synchronous -- is a DEDICATED-worker-only premise, so a SharedWorker cannot host OpfsFs as written and every \"browser worker\" in that argument means a dedicated one. The trap for anyone costing multi-tab: Sessions, the Addressed filter and the single frame pair are all ready and none of it helps, because the no is entirely on the storage seam. See improvise-12ha for the three probed ways out (async seam / nested worker / relay) and improvise-7gip for the guard that fails to enforce this.').
invariant(one_display_width_truncator,'src/ui/text.rs::truncate is the one display-width-safe truncator the TUI widgets share; grid.rs and formula_panel.rs call it. import_wizard_ui.rs keeps a separate BYTE-measured truncate deliberately, because its measure decides where every wizard line wraps and bytes are never fewer than cells. The sibling hazard is a byte count used as a COLUMN OFFSET (inner.x + name.len()), which never panics because Buffer::set_string clips, and so shows only as a gap widening with each non-ASCII character.').
invariant(page_items_ride_inside_cycle,'RenderPageCycle carries the item list INSIDE cycle rather than beside it, so PageAxis::cycle is one predicate and not two (improvise-rw6l). A Filter axis has no cycle, and therefore gets no down-arrow, no menu, no items, is painted as a span rather than a button, and Click::PageItem composes nothing if a click reaches it -- four answers off one home, which is gotcha 27 holding. items.is_some() appears nowhere in the browser. This is stronger than the guard originally asked for (a test that a Filter axis gets no arrow): the wrong state is not constructible.').
invariant(one_format_plan_per_render,'FormatPlan REPLACED the (comma, decimals) pair in every render path -- GridLayout::display_text, viewport::grid_body and compute_col_widths, project::grid_in, export.rs::render_csv, ui/app.rs -- rather than being added beside it. Two answers to \"what format is this cell\" is the disease (design-principles section 5, improvise-edp).').
invariant(format_ownership_is_formula_for_order,'A cell format comes from the FIRST formula in model order whose target coordinate matches, the same rule and order that decides whose VALUE it is. A match naming no format falls through to the view -- a match, not a miss. Found by review: a HashMap keyed by target made the SPELLING hash-dependent while the value was not (gotcha 18 shape in a new place), and skipping unformatted formulas let a later FORMAT reach a cell it does not produce.').
invariant(a_column_total_is_resolved_from_its_column,'GridLayout::column_key(col) -- the page and filter constraints plus the column tuple, the coordinates surviving the ROW aggregation. Reading row 0 let a row-axis formula format reach a total that had summed that row away, and made the spelling depend on row order. Found by review after context/code/core.md had ARGUED the row-0 shortcut was safe.').
invariant(baseline_is_measured_in_your_own_worktree,'A dispatch brief carried 1638 as the test baseline; 7470b61 measures 1645. The orchestrator had copied the number from the PREVIOUS agent report, taken at 364052b, before PR #123 merged and added seven tests. An agent trusting it would have read +11 for four new tests and gone hunting. Measuring the baseline at YOUR OWN base commit costs one run and is the only thing that makes the delta checkable -- a number carried forward from another report is a claim about a commit you are not on.').
invariant(a_hidden_toast_keeps_its_text,'The toast element is the record of what was last said and is NOT emptied when it hides (improvise-n6t8). harness/tests/drill.spec.js discriminates between two refusals by reading it, so an emptied node would satisfy not.toContainText(\"aggregate\") VACUOUSLY the moment the timer fired -- a passing assertion about an element with no content. Argued in web/index.html and context/code/clients.md.').
invariant(headless_cmd_and_script_are_one_function,'run_headless_script DELEGATES to run_headless_commands, so \`improvise cmd\` and \`improvise script\` cannot diverge in what they print, what they exit with, or when they write the document back. Asserted by a_script_reports_exactly_as_cmd_does rather than left to the delegation, because a future caller could inline it.').
invariant(the_creating_columns_insert_and_so_suppress_the_advance,'A +cat or +measure commit that NAMES something inserts a column at the cursor own index, so an advance computed from the PRE-commit layout is wrong for both -- CursorState::col_count is stale because apply_effects rebuilds only after the batch (gotcha 23). inserts_a_column covers both creating columns and takes the committed VALUE, because an empty name creates nothing and is genuinely at the right edge. created_name is the one home of that rule, read by add_measure, add_category and the predicate.').
invariant(a_reported_share_cannot_overflow_its_scale,'gp_tree::fit::importance_ranking sums and divides in f64 and narrows only the resulting share, which is bounded by 100 and therefore always finite (improvise-k844). TWO overflows, not one: 100.0*imp[j] associates left so a lone importance over f32::MAX/100 is inf before the divide that would have returned it to range; and the f32 TOTAL itself overflows when several are large, which reassociating to 100.0*(imp[j]/total) turns from NaN% into a SILENT 0% FOR EVERY FEATURE. f64 closes both, since no sum of finite f32s can overflow it. The residual guard answers only an importance that ARRIVES non-finite -- an infinite total apportions nothing, so the ranking is empty rather than NaN, and note NaN <= 0.0 is false so the pre-existing early return passed NaN straight through. Reachable PAST improvise-pv3g because both callers score importance in the target OWN units, making it an MSE difference that grows as the target square.').
invariant(viewport_extent_is_external_on_both_front_ends,'The viewport extent is an EXTERNAL INPUT that reaches the document, and it is not a browser problem. run_headless_commands reads crossterm::terminal::size() into app.term_{width,height} and CmdContext::{visible_rows,visible_cols} derive from it: 80x24 gives visible_rows 16, 200x60 gives 52. Measured on a 40-row grid, the SAME command sequence -- one PageDown then clear-cell at the cursor -- deleted R12 at 80x24 and R39 at 200x60. So Client::set_viewport is not a browser-introduced input; it is the browser SPELLING of one the TUI has always had, and gotcha 47 \"not derivable\" is true of both front ends. Any ADR treating extent as a browser problem produces a log that replays in a browser and not in a terminal. Cheapest of the five external inputs to close -- one pair of integers with an existing wire spelling, ClientFrame::Viewport -- but it must land in the SAME CHANGE as improvise-6uva, which currently masks it. A test must drive TWO sizes; one cannot see this.').
invariant(aggregate_hash_is_layout_scoped,'A RecordHash falls back to DataStore maintained index and so resolves against the DOCUMENT, which is why a connected client SetCell works from any view. An AggregateHash has no such index AND CANNOT HAVE ONE -- an aggregate has no existence outside the layout that derives it. So App::resolve_aggregate answers only for the HOST active view, which since improvise-jytm is not the client. Guarded by an_aggregate_a_session_was_shown_does_not_resolve_against_the_hosts_view, whose CONTROL is the load-bearing half: the same surrogate resolves once the host is moved onto that view, so a blanket None -- a typo in the hash, an empty layout -- cannot pass.').
doctrine(bug_first,'write a failing test demonstrating the bug before fixing it').
doctrine(unit_over_e2e,'prefer small unit tests over integration/e2e').
doctrine(suite_speed,'Two numbers, because the ambiguity between them is what let this drift: NO SINGLE TEST BINARY may exceed 2 seconds, and the WORKSPACE WALL CLOCK is the sum of them. Measured at 22d43b2: 1309 tests over 33 binaries, ~2.2s wall, longest single binary 1.42s -- so the workspace is already past 2s and the rule that bites is the per-binary one. Re-MEASURE rather than re-assert: cargo test --workspace prints a \"finished in\" per binary. Excludes compilation, and excludes harness/ (Playwright, minutes, its own CI job).').
@@ -313,6 +326,48 @@ doctrine(a_merge_prints_pop_whether_or_not_it_stashed,'On a conflicted merge wit
doctrine(a_missing_loose_ref_is_not_a_deleted_branch,'A watcher reading .git/refs/heads/** directly -- which a worktree-isolated agent must, because the shell guard refuses git in a loop -- cannot distinguish a DELETED branch from one that pack-refs moved into .git/packed-refs. Both present as a vanished file. Observed together in a single event: wave/q-keymap was genuinely deleted by its PR merge while wave/r-browser-page was merely packed. So a MISSING event is a PROMPT TO CHECK WITH GIT, never a conclusion; pair it with git branch --list before acting.').
doctrine(rebuild_the_integration_on_the_new_base_and_diff_the_trees,'When part of a wave merges upstream while an integration is held, rebuilding the integration on the NEW base and diffing the two trees is the strongest available evidence that the upstream merges lost nothing. 2026-09-21: git diff --stat between the locally-integrated preview (old base + four local merges) and gh/main-plus-the-remaining-branch was EMPTY -- byte-identical. That answers the clean-auto-merge hazard DIRECTLY rather than by inference, and every gate run on the first tree transfers to the second by identity. Cheaper than re-gating and strictly better than assuming, because a clean auto-merge can delete tests with no conflict and no compile error.').
doctrine(a_reported_sha_is_correct_and_short_lived,'Three times in one session an integration report named SHAs that were accurate when measured and stale within minutes -- twice because agents pushed self-review fix commits, once because three PRs merged upstream while the preview was held. The remedy is a watcher, not diligence: heads were verified at the start AND the end of a pass and the table was still stale, because the moves landed after the last check. A report that names no SHA is not checkable; a report that names one has a shelf life. Re-read immediately before acting on it -- and the same applies to a SHA written into a PR body.').
doctrine(an_empty_actions_log_is_retention_not_history,'gh run list came back empty for the improvise release.yml workflow and was read as \"only one tag push ever ran this\". GitHub Actions logs age out at 90 days and the rc1/rc2 tags were five months old; both carry COMPLETE published releases (all four targets, sha256 files, dist-manifest.json, source.tar.gz, installer), so the workflow had demonstrably run end to end the last time a tag and the version agreed. The artefact a run PUBLISHED outlives the run, so ask gh release before gh run -- and gh run list --workflow=release.yml --event=push keeps the tag history even where the logs themselves are gone. Two further claims in the same passage failed the same way and are worth knowing as a set: a recurrence was inferred from run DURATIONS when only one of the three failures had ever been diagnosed (an identical signature is not an identical cause -- the rc1 logs aged out and its failures sit on initial-setup commits, where plan could have failed on an incomplete dist config), and the remotes a tag sat on were asserted rather than asked. Ask; then note that the asking has a shelf life too.').
doctrine(a_verified_remote_fact_has_a_shelf_life,'v0.1.0-rc3 was measured three times in one session and read differently each time: present on all three remotes and locally (asserted, from the aa4e901 commit message), then on gh alone (git ls-remote), then nowhere at all -- the tag was deleted mid-review. Only the FIRST reading was careless. The second and third were each taken correctly, by two different people, and they contradict. So the rule is NOT ask-rather-than-assert: a verified answer about a remote, a tag, a deployment or a dashboard DECAYS, and prose cannot distinguish a stale line from a live one, so a reader following a checklist at 2am cannot tell which they hold. A document that records an inventory is therefore wrong on a schedule it does not control. Record HOW TO ASK (git tag -l, git ls-remote --tags <remote>) and say in the document that no inventory is recorded, so the blank reads as deliberate rather than as an omission to helpfully fill in. THE KEEPER IS THE ONE WHOSE SUBJECT CANNOT CHANGE: \"the commit message was wrong\" is durable, because its subject is a fixed text; \"the tag is on gh alone\" was true and is now false. That is the test for whether a sentence belongs in context/ at all. The half-lesson that stops one step short, and the reason this fact exists: the gh-alone reading was FOUND by running the command, from which the obvious conclusion is run-the-command -- and that was written into two files as though it settled the matter. Running the command is necessary and INSUFFICIENT, because the answer expires. Four copies of the volatile half had to be removed, one of them in the header of scripts/release-preflight.sh, which is the file a reader opens first.').
doctrine(a_ready_ticket_may_already_be_fixed,'improvise-pv3g was dispatched to an agent as an open P1 and was already fixed on main: f616487 was an ancestor of the very commit the agent was told to branch from, and bd still showed the ticket OPEN because nobody closed it when the work landed. improvise-uej8 looks the same, named in 7a44616 via PR #114. So bd ready is a list of tickets nobody closed, NOT a list of work outstanding -- the two coincide only while closing is perfect, and in a session with several agents landing several PRs it is not. THE CHECK IS CHEAP AND BELONGS IN EVERY BRIEF: reproduce the bug at HEAD before implementing, and if it does not reproduce, find the commit that fixed it, confirm it is an ancestor of the base, and report the SHA instead of opening a PR. The agent that found this built the ticket fixture and ran the SHIPPED BINARY at the commit before the suspected fix, in a throwaway worktree, showing the bug in both directions rather than inferring from a commit message. The failure this avoids is not wasted time: it is a fabricated fix for an already-fixed bug, which can undo the real one and costs a reviewer trust.').
doctrine(a_refusal_the_headless_path_discards_is_not_a_refusal,'improvise-pv3g acceptance asked for an actionable refusal naming the cause, and the wording was written, tested and correct. run_headless_commands (src/main.rs) applies effects and never prints view_state.status_msg, and exit_code is set to 1 only for PARSE errors -- so through \`improvise cmd\` a refused command is indistinguishable from a successful one: silence and exit 0 either way. The TUI renders the status line, so the criterion is genuinely met there and the headless path is where it evaporates. No test sees it because a_refusal_names_what_went_wrong_and_stores_nothing asserts the EFFECT status, and the effect is correct; nothing asserts the binary emits it. Filed as improvise-j6x8. The shape to carry: when acceptance says a user is TOLD something, name the surface -- an effect carrying a message is not a user being told, and the layer that drops it is usually one the test suite does not drive.').
doctrine(a_prose_count_is_checked_by_no_one,'context/code/tui.md said mode_style spells FOURTEEN default-chrome AppMode variants; it spells thirteen, verified against src/draw.rs. The match is exhaustive and sound -- only the sentence was wrong, and nothing in this repo can check a number written in prose. Same failure mode as gotcha 38 subtracting SwitchView and forgetting its two siblings, and gotcha 44 listing gaps that had already closed. THE REPAIR SHAPE: write the ARITHMETIC beside the count so a reader can check it (13 + Editing/RecordsEditing + CommandMode + TileSelect = 17 = the AppMode variant count), and say when it was wrong.').
doctrine(a_rendered_string_is_pinned_wherever_anything_reads_the_screen,'improvise-771e changed the browser tile bar from Axis::short_label to plain_label. The orchestrator pointed the agent at tests/grid_frame_agreement.rs as the thing that might pin the vocabulary; it does not mention the tile bar at all. harness/tests/drill.spec.js DID -- it asserted the tile text exactly, twice, with \"Quarter Pag\" AS THE EXPECTED VALUE, a Playwright spec in a directory the ticket never named, asserting the bug as its expectation. So when changing a rendered string, GREP FOR THE STRING, not for the function that produces it: a screen-reading test pins the output wherever it lives, and the producer name will not find it. A now-false comment naming short_label in contract.spec.js was found the same way.').
doctrine(a_duplicated_inheritance_edge_is_invisible_to_a_behavioural_test,'improvise-wiew: parenting RecordsNormal to document_keymap DIRECTLY, when it already reaches it through Normal, is a fault the behavioural test cannot see -- the key still works, so pressing it proves nothing. Only records_normal_inherits_the_document_layer_once_through_normal, which asserts the CHAIN IDENTITY (exactly four deep), catches it. The same asymmetry appeared in PR #112: removing a mode keymap parent failed ONLY the Arc::ptr_eq chain guard while 38 behavioural keymap tests passed, because the mode own AnyChar answered identically. A keymap chain needs a structural assertion beside the behavioural one; the behavioural one is satisfied by any arrangement that happens to route the key.').
doctrine(distrust_a_context_paragraph_that_argues_a_case_cannot_arise,'context/code/core.md claimed the row-0 totals shortcut was safe \"for the case it cannot arise in\". It arises: a formula target category is any category, so it can own a row-axis item. Such a paragraph is doing the work a type or a test should, and nothing checks it. Note who wrote it: the same agent that had just read the code and had, in the same edit, correctly closed a gap core.md itself had named. Reading the code is what produces both the good paragraph and the wrong one.').
doctrine(a_clean_merge_is_not_a_tested_merge,'harness/tests/pages.spec.js arrived on main from PR #123 while PR #125 was in flight, asserting a display literal the new decimal default changes. The merge was CLEAN and the combination was WRONG, and only CI merge ref had ever run both sides -- the local run was green on a branch that had never seen the file. So a PR whose change is cross-cutting should merge main and RE-RUN THE GATES before calling itself green, not after CI says otherwise. This is design-principles section 7 third case, arriving again.').
doctrine(rebuild_the_assembled_site_after_merging_web,'harness/serve is gitignored and built once. After a merge touching web/, Playwright failed 8 tests with \"Quarter Pag\" vs \"Quarter Page\" and missing elements -- nothing was broken: harness/build.sh had run BEFORE the merge, so serve/ held pre-merge web/*.js against a post-merge spec. Re-run web/build.sh and harness/build.sh after any merge touching web/, or debug a product that is not what is being served. The failure mode is indistinguishable from a real regression.').
doctrine(a_none_that_carries_two_facts_is_a_black_hole,'KeymapSet::dispatch spelled construction as registry.interactive(name,args,ctx).ok()?, so Option::None meant BOTH \"no binding matched\" and \"a binding matched but would not build\" -- and App::dispatch_key answers None with Dirty::NOTHING and no status, so a bound key was silent. improvise-55l5 split them: None means no binding matched and nothing else; a construction failure returns Some(vec![set_status]). THE TEST PAIR IS THE POINT, because one test cannot separate two facts one value carried: the rival that swallows the error fails a_bound_key_whose_command_cannot_be_built_says_so, and the rival that ALSO reports a failed lookup fails an_unbound_key_stays_silent -- without that control the fix writes a status for every unmapped character in every mode.').
doctrine(surface_the_error_class_no_static_check_can_see,'improvise-55l5 separated the constructor errors into two classes. CLASS A reaches a user and nothing static can see it: \"no cell at cursor\" from six registrations reading ctx.cell_key(), which is STATE-dependent, so no registration-time check applies -- and the worst case is commit-cell-edit, where the user has typed a value in Editing and the batch is discarded. CLASS B is keymap-authoring mistakes that every_bound_command_name_is_registered already refuses at test time, checking both halves of the old expression (gotcha 15). SO THE REPRO THE TICKET ITSELF PROPOSED WAS THE ONE CASE THAT CANNOT HAPPEN. Class B surfaces anyway, deliberately: a list of blessed errors would be a second description of which failures matter, free to disagree with the first.').
doctrine(cleanup_done_by_one_of_two_call_sites_belongs_to_the_command,'AddRecordRow returned SetCell and never placed the cursor, so the frame observation invalidated it to the origin mid-batch and o opened an editor on ROW 0 -- renaming an unrelated record and moving every measure cell in it (gotchas 1 and 24). But CommitAndAdvance Tab-at-bottom-right path had ALWAYS placed the cursor, at the CALL SITE. One of two callers doing the cleanup is the tell: the placement was a property of the caller where it is a property of the command. Look for this wherever two call sites of one command differ in what they do AFTERWARDS.').
doctrine(a_reserve_that_caps_hides_its_own_failure,'A fixed-size slot sized by eye needs a guard, and the guard can only see an overflow that GROWS the box. A width or max-width absorbs an over-long value inside a box that never moves, so every bounding-box measurement stays identical and the regression is invisible. Reserve with a FLOOR (min-width) and let the measurement catch the day the reserve stops fitting. improvise-n6t8: web/style.css #mode is min-width: 21ch, and removing it fails both \"the bar moved in CATEGORIES\" and the 16-char check-the-checker. The general form: when a guard measures a consequence, choose the mechanism whose failure PRODUCES that consequence rather than absorbing it.').
doctrine(dismiss_a_repaint_driven_surface_by_identity_not_by_timer,'A transient UI over a value that is STATE rather than an event must key its dismissal on what it last ACCEPTED, not on a timer. ViewState::status_msg is re-offered by every repaint -- web/app.js calls the sink unconditionally and repaint runs on every keydown -- so a setTimeout alone resurrects the message on the next keypress, which is worse than a message that simply stays. improvise-n6t8 keeps what createToasts last accepted, and keeps it AFTER the toast hides, so an equal offer is recognised as a repaint and dropped. COST, STATED RATHER THAN HIDDEN: two identical messages in a row show once. That is forced -- nothing beside status_msg carries a sequence and model.revision moves for a cursor key as readily as for a re-issued command, so of the two readings of an ambiguous offer only one avoids reintroducing the bug.').
doctrine(a_user_route_cannot_separate_what_a_user_cannot_separate,'improvise-bd9o guards on state.grid.records rather than on the mode, and the whole argument turns on those two facts being different things. TWO INJECTED RIVALS -- view.mode.is_records() and the keymap mode.is_records() -- PASSED THE ENTIRE WORKSPACE. The end-to-end route test could not see them for a structural reason: it enters records mode the way a user does, where the grid and the mode AGREE. An end-to-end test is the right test for \"is this route silent\" and the WRONG test for \"on which of two agreeing facts does this guard depend\". When a fix is argued on a distinction only DRIFT can expose, the test must STATE the drift rather than reach it -- build the client whose grid and mode disagree. The tell to look for: a paragraph arguing a choice that no existing fixture can exhibit.').
doctrine(guard_the_entrance_not_the_arm,'improvise-bd9o: a guard on the CellId::Record arm answers \"is this CELL one we cannot commit\" where the question is \"is this GRID one we cannot commit in\". The separating case is the coordinate column, which carries NO CellId at all -- cell.id.as_ref()? short-circuits, so an arm guard never runs and the key does nothing silently. The guard therefore sits before cell_at_cursor, on the observed grid. The general form: when a refusal is about the CONTEXT, guarding a branch inside the context leaves every path that never reaches the branch unguarded, and those paths are the ones that fail silently.').
doctrine(a_kind_a_name_cannot_carry_must_be_passed_not_re_derived,'CategoryKind::of_name reads Label as Regular BY DESIGN, and that single fact produced improvise-dy3j and improvise-cj4u at two different layers: a file format with nowhere to write the kind, and View::on_category_added asking of_name instead of being told. The cj4u fix is THE SIGNATURE -- passing the kind is what made the compiler name every call site -- and it deleted the two Workbook set_axis(name, Axis::None) corrections that were a second answer to \"where does this category go\", of which only ONE of two callers knew to write it: add_label_category fixed the views existing at that moment while create_view, holding only names, pivoted the label onto Row. Ask of any stand-in accessor which distinctions it deliberately collapses, and then which callers those distinctions matter to.').
doctrine(a_section_header_is_not_a_place_to_put_a_modifier,'improvise-dy3j proposed \"## Category: Note [label]\" and improvise-pczv forbids it: header_name takes the name as the WHOLE line, totally, so a suffix is indistinguishable from a category literally named \"Note [label]\" -- adopting it would make a legal name unspellable and re-open the ambiguity header_name exists to close. The modifier goes INSIDE the section as an entry line, with a real token (\"kind: \") rather than a lookahead, because the grammar-walking generator reads the grammar through pest_meta and SKIPS predicates. A ticket that proposes a format syntax has usually read the writer and not the grammar.').
doctrine(agreement_between_two_wrong_copies_is_not_evidence,'A lockstep test that only compares two front ends PASSES when both derive the same WRONG answer -- which is the EXPECTED outcome of a duplicated derivation, not a passing grade. improvise-vw47: both_ends passed against the bug, because cmd/panel.rs and resolve.rs both computed format!(\"View {}\", len + 1) and were wrong identically, so the two documents agreed perfectly. Only the assertions on the RESULTING DOCUMENT caught it -- the view count must rise, the survivor format must be untouched. \"The two ends agree\" and \"the two ends are right\" are different questions, and a duplicated derivation is exactly the case that separates them. A cross-front-end test must assert that what the ends agree on is RIGHT, as well as that they agree.').
doctrine(a_field_with_no_severity_cannot_be_split_by_guessing,'improvise-q914: the orchestrator brief proposed stdout for a status and stderr for a refusal. ViewState::status_msg is a bare String with NO severity, and BOTH candidate classifiers are disproved by code already in the tree -- \"a refusal aborted\" is false, because effect::AddItem writes its Err to status_msg and does not set abort_effects, so that rule labels a refusal a SUCCESS; and \"a refusal is anything that spoke\" is false, because cmd/fit.rs sets a status on the SUCCESS path carrying the reproducing seed, so that rule labels a deliverable a FAILURE. Both verified in-tree by the orchestrator. Choosing either is design-principles section 7 adjacent-check defect. The honest answer was one undivided stream, and the real fix is a severity on the field -- a wire change, filed as improvise-udt2 naming both disproved rivals so the next attempt does not rediscover them.').
doctrine(a_narrow_exit_contract_beats_an_overpromised_one,'improvise-q914 asked for a non-zero exit when an effect is refused. Delivered: exit 1 on a parse error or App::last_batch_aborted -- both REAL flags -- and no further, with a status-only refusal printed but still exiting 0 because nothing can classify it. README states it plainly: exit 0 means \"nothing aborted\", not \"everything worked\". A contract that promises less and is TRUE is worth more than one that promises the ACs wording and is guessing, because a script author who trusts the wrong one proceeds on a no-op. The shortfall is recorded on the ticket and filed rather than glossed.').
doctrine(observe_dirty_per_command_because_save_clears_it,'The headless write-back gate accumulates model_state.dirty PER COMMAND rather than reading it once at the end, because effect::Save CLEARS the flag (gotcha 45). Reading it once would make \`[add-item East, w other.improv]\` look unchanged and SILENTLY DROP the edit from the -f document. Same shape as the other observation gates in this repo: what is observed must be sampled where the thing being observed happens, not where it is convenient to look.').
doctrine(key_outcome_variants_are_protocol_surface,'improvise-h3y7 audited the wire vocabulary for COMPLETENESS, enumerating Command and ModelCommand against the effects in both directions, and found nothing wrong here. The gap was in the SHAPE of what a resolver may RETURN: KeyOutcome was Local | Upstream | Ignored, with no way to say \"send this frame\" -- so ClientFrame::Save and its Host::handle arm both existed, since improvise-368h, with NO KEY ABLE TO REACH EITHER. An enumeration that stops at the frames and the commands will miss the next one the same way. KeyOutcome variants are part of the protocol surface and belong in any completeness pass.').
doctrine(a_self_constructed_frame_is_not_reach,'a_client_can_ask_its_host_to_save BUILDS the frame it feeds to the host, so it proves the host ANSWERS the frame and never that anything can SEND one. improvise-368h therefore shipped a feature no keypress could reach, with a passing test over it -- \"a green suite is not evidence\" in its purest form. The repair is an end-to-end test from the INPUT: improvise-jz6w asserts Ctrl-S on a real Client produces the frame, a real Host answers it, and the document comes back off storage. WORTH SWEEPING: check whether other Host::handle arms have only self-constructed-frame coverage.').
doctrine(a_review_comments_commit_id_is_per_comment_not_per_review,'Measured on PR #122 and re-verified by the orchestrator against the live API: ONE review submitted 19:48-19:53 carries five inline comments, THREE naming 246e8f4a and TWO naming 6a4c7dcd, while its body names 246e8f4a. GitHub pins each comment to the file version it was written against, so a comment on an outdated diff position keeps the older commit while its siblings move on. Two consequences: a per-entry \"EARLIER head\" flag can make a CURRENT review look partly stale, and a head comparison against the review BODY is not even a proxy for its own comments -- the old pr-reviews head check was answering about the WRONG OBJECT, not merely reading too few of them.').
doctrine(a_multi_assignment_local_is_not_left_to_right_on_bash_5,'\`local n=\"$1\" name=\"$2\" f=\"$DIR/$n.$name.json\"\` creates EVERY name before running the assignments on bash 5, so the third clause reads an unbound variable and set -u kills the shell. It works on macOS bash 3.2. scripts/pr-reviews-test.sh therefore passed 19/19 LOCALLY and 2/19 IN CI ON THE SAME COMMIT. Give each local its own statement -- and note the real lesson: a shell checker must be wired into CI in the SAME change that adds it, because the platform difference is invisible to the machine that wrote it.').
doctrine(a_dead_command_substitution_wears_jqs_exit_code,'Both bash-5 bugs in scripts/pr-reviews failed as EXIT 2 WITH NO OUTPUT AT ALL, and 2 is jq answering an invalid --argjson with a USAGE error. When set -u kills a command substitution, or gh api --paginate returns several concatenated documents, the empty or malformed string reaches jq and the shell fault at the TOP of the script is reported by the JSON tool at the BOTTOM. Exit 2 plus total silence means \"something upstream of jq produced nothing\", never \"jq computed a wrong answer\".').
doctrine(a_fixture_must_be_able_to_hold_the_shape_the_live_path_produces,'gh api --paginate emits one JSON array PER PAGE, so a long thread is several concatenated documents. scripts/pr-reviews flattens the live AND the fixture path through one pages_to_array, so a fixture file can carry two concatenated arrays. A fixture that could only hold a single array would exercise a shape the live fetch never produces -- a check adjacent to the thing it names. No PR in this repo has yet reached a second page, so the test is the only thing that will see it before a user does.').
doctrine(a_prose_argument_is_code_and_can_be_the_bug,'context/code/command.md stated that the measure + \"is the last column, so there is no column to its right for an index to shift into, and both advances are already special-cased at the bottom-right edge\". Verified verbatim on main before the fix. THE FIRST CLAUSE WAS FALSE -- the column that shifts is +measure itself -- and THE SECOND NAMED THE BUG AS A SAFEGUARD. improvise-0ygd is the defect that argument authorised. A wrong argument in context/ is not documentation debt: it is the defect, written down, and it will be read as a reason not to look. This repo keeps its most expensive artefacts in prose that nothing checks, so a paragraph asserting why a case cannot arise is the shape to distrust -- the third instance this session, after the row-0 totals shortcut and gotcha 38 arithmetic.').
doctrine(a_missing_affordance_claim_must_render_the_screen,'improvise-p8ls claimed a TUI user has no on-screen route to help or out, and was demonstrated VIA THE HELP PRINTOUT -- a function return value -- where the claim was about WHAT IS ON SCREEN. Those are adjacent questions. draw_title has right-aligned \" ?:help :q quit \" unconditionally, every mode, every frame, since da93145 on 2026-04-01; a printout of advertised() cannot see a string draw_title renders. A ratatui TestBackend is cheap, already precedented in src/draw.rs (bottom_bar_tests::bottom_row), and settles it in one run. The ticket EVIDENCE was accurate and its CONCLUSION did not follow: the author checked advertised() and draw_welcome and concluded those were the only homes, where the title bar is a third and is the one always visible -- grepping the FACT rather than the function finds four.').
doctrine(fix_every_sibling_sentence_or_the_paragraph_contradicts_itself,'The orchestrator corrected gotcha 47 to say ADR 001 clause 4 IS delivered, and left the next three sentences reading \"The fourth is open because App::apply_effects aborts without undoing\" -- a paragraph contradicting itself within one screen, produced by the very edit that was fixing the contradiction. Caught by reading the patched line back rather than trusting the substitution count. This is gotcha 48 turned on its author: a fact stated in more than one place is changed by a grep, and the places include the SENTENCES AROUND THE ONE BEING EDITED, not only other files.').
doctrine(a_rollback_needs_a_positive_acknowledgement_not_only_a_refusal,'improvise-nxah: a client with two mutating frames in flight cannot tell which one a Rejected answers, so ServerFrame::Accepted is pushed once per accepted mutating frame, LAST, after everything the frame produced -- the same ordering argument Projection::Basis has, and the same shape as the publication gap ADR 001 originally left. And a SINGLE undo slot is unsound rather than merely imprecise: a snapshot taken before the first of two outstanding frames discards the second, which the host DID apply, diverging in the opposite direction. Hence settled plus an in_flight queue that drops one frame and replays the rest.').
doctrine(a_hard_floor_across_widths_names_the_container_not_the_child,'improvise-tg27 suspected a flex min-width:auto trap swallowing #grid overflow. documentElement.scrollWidth read 1011 AT EVERY WINDOW NARROWER THAN 1011px -- a FLOOR, not a slope. A floor is the signature of a sum of flex:none RESERVES; a slope would have meant a clipped child. The reserves were the app bar (#build-version 14ch + #doc-name 34ch + #view-name 16ch + #mode 21ch + a non-shrinking #status), added the day before by improvise-n6t8, so the PAGE grew the scrollbar while main stayed window-width. Read the SHAPE of the measurement across the parameter before reading the code: constant-then-correct names a floor, and a floor names whoever set it.').
doctrine(never_hide_the_overflow_you_are_asserting_the_absence_of,'improvise-tg27 rejected body{overflow-x:hidden}, which would have made scrollWidth == clientWidth UNCONDITIONALLY and rendered its whole spec vacuous -- every assertion still passing, the bar still not fitting. The check would then answer \"is overflow hidden\" where it means \"does the content fit\". A fix that makes the measurement unable to fail is not a fix, and it is especially tempting in CSS, where suppressing a symptom and correcting a layout look equally like one line.').
doctrine(a_deferral_cost_must_be_measured_not_named,'improvise-k844 sat open because context/code/gp.md section 7 deferred it on the claim that reordering the arithmetic \"moves the bit patterns misclass_rate_and_importance_are_bit_for_bit_what_they_were pins\". That test pins permutation_importance RAW SCORES; importance_ranking consumes them AFTERWARDS, so no path it witnesses runs through the share -- and scoring_known_answers.rs is byte-identical across the actual fix, verified by the orchestrator as absent from the diff. A COST PREDICTED FROM A TEST NAME RATHER THAN FROM WHAT IT CALLS is how a one-line fix stays filed, and it reads as diligence, which is why it survives review. Measure the claimed cost or state none. Pairs with an_invariant_is_made_unrepresentable_not_validated: the clamp improvise-pv3g added instead was a call-site check that left inf% in the data behind it.').
doctrine(a_reported_number_can_overflow_while_every_input_is_finite,'The gp crates learned this twice. standardize sums (t-mean)^2 in f32 (improvise-pv3g) and importance_ranking scaled a share by 100.0* in f32 (improvise-k844), and the second was REACHABLE PAST THE FIRST. Both are fixed by making the arithmetic incapable of it -- a Scaling that refuses a non-finite part, an f64 accumulator -- never by a call-site check, and pv3g own f32 clamp WAS that call-site check: it stopped the abort and left inf% in the data behind it. The silent twin is the half to look for: pv3g had cv_regress pooling a fold it could not standardize, k844 had an f32 total going inf with no panic to announce it. Ask what ELSE consumes the derived quantity.').
doctrine(a_dev_shell_is_a_third_environment,'.envrc puts nix bash 5.3 on PATH -- the RIGHT INTERPRETER, CI semantics -- and pkgs.mkShell ALSO exports about thirty generically-named lowercase variables the runner does not have: name, out, outputs, system, shell, builder, stdenv, phases, buildInputs, doCheck and more. A script reading one by accident under set -u is SILENT IN THE DEV SHELL AND FATAL IN CI. improvise-f9qf: pr-reviews-test.sh passed 19/19 under macOS bash 3.2 AND 19/19 under the dev shell bash 5.3, while failing 2/19 in CI -- two different reasons for green, one of them the correct interpreter, and unsetting \`name\` alone reproduced CI 2/17 exactly. Note nix shell does NOT export name and nix develop DOES (verified with env -u name as the control), so the dev shell is the one nix entry point that masks this class. The orchestrator own verification of this was itself confounded by inheriting name from a direnv-loaded shell -- it answered \"does name end up set\" where the question is \"does nix shell set it\".').
doctrine(an_observation_is_a_function_of_the_trajectory_not_the_state,'improvise-ilvl claimed \"an observation is a pure function of state\". It is not -- layout_version, cursor_epoch and model_state.dirty all differ between two routes to an IDENTICAL document (0/0/false against 2/2/true). The weaker true claim, that they are reproducible FROM THE LOG, is all replay needs. But the stronger false one is exactly what would justify SNAPSHOT-PLUS-SUFFIX LOG COMPACTION, and that is wrong silently in the direction that looks like it works: Basis puts document_generation and layout_version ON THE WIRE and clients compare them for EQUALITY, so a compacted replay is not basis-compatible with any client holding a claim, and dirty is the worker OPFS persist trigger so the same document reached by a different route persists a different number of times.').
doctrine(a_determinism_check_can_pass_because_the_divergent_path_is_unreachable,'Three identical replays of a 26-line script fixture proved the script path deterministic and proved NOTHING, because the fixture cursor moves were silent no-ops (improvise-6uva) -- the divergence had no way to occur. The defect surfaced only when clear-cell FAILED TO PARSE for want of coordinates. Sharper form of \"a green suite is not evidence\": a passing determinism or idempotence check must be shown to be capable of FAILING, by driving the input the check exists to discriminate. It is also how one defect MASKS another -- improvise-up8g viewport hazard is invisible in improvise script precisely because 6uva discards the cursor, so fixing 6uva makes up8g live and they must be closed together.').
doctrine(an_injected_fault_is_not_reverted_until_the_full_suite_is_rerun,'An agent injected two rivals and reverted one onto the WRONG OCCURRENCE: replace(\".is_measure()\",\".is_index_or_dim()\",1) hits the FIRST match in the file, which was drill_constraint pre-existing predicate rather than the injected one. The commit shipped with TWO faults where it had demonstrated one. Every gate re-run afterwards PASSED over the broken logic -- clippy, fmt, check-evidence, both node suites, the eight-crate wasm gate, the full Playwright run -- because the one gate that could see it, cargo test --workspace, had been measured BEFORE the injections and never again. The PR body therefore carried a figure that was checkable and false. Caught by ChatGPT Codex review. THE RULE: the targeted test going green says nothing about what the revert did on its way past, so the full suite must be re-run after every revert.').
doctrine(a_fixture_that_names_its_own_precondition_must_read_it_back,'A test asserting that a session was shown something must READ THE VIEW OFF THE SESSION, not hard-code the name. A first version derived its grid from grid_in(&app,\"Other\",..), so the session, the switch and apply_checked could ALL BE DELETED and every assertion still passed -- it proved a grid built for a NAME while its name claimed a grid a SESSION WAS SHOWN. The repair reads sessions.get(id).view().active_view, asserts it MOVED, and asserts it DIFFERS from the host: the assert_ne! is what pins the premise, since assert_eq!(seen,\"Other\") alone passes if the host moved too. Found by review inside a PR whose whole subject was a scoping distinction -- the characteristic defect appearing in the change that documents it.').
work_done('improvise-8tk',bug_fix,'ZZ/:wq quit even when the save failed, silently discarding the model. Save now aborts the effect batch on failure; save_to_current_path/1 returns Result<String,String>; three tests in ui/effect.rs.').
work_done('improvise-907',bug_fix,'Import wizard step machine redesign: validation_error() gate at ReviewProposals (no-category and over-limit), history-stack previous() bound to Left on every step, and Done made a real confirmation step holding the built workbook. Superseded improvise-gfq, improvise-o1c, improvise-c6j.').
work_done('improvise-6sq',wont_do,'Merging EditOrDrill and EnterEditAtCursorCmd into one EnterEdit{mode} is not safe: they differ in WHEN the cell value is read, not in a mode parameter. Added ui/app.rs::tests::i_and_o_diverge_on_an_empty_records_grid as executable evidence.').
@@ -344,6 +399,26 @@ work_done('improvise-2f97',feature,'kb_error(dangling_citation/3): a name cited
work_done('improvise-dj1l',bug_fix,'scripts/kb-merge.pl matched clause keys by unification, which lost and corrupted clauses: two variant-headed rules in one delta went two in, one out (536 before, 2 in delta, 537 after), and a non-ground rule over a predicate holding facts deleted one AND was written with its variable bound. See invariant(key_matching_is_variant_equality_never_unification). Five regression cases added to scripts/kb-test.sh, including one that injects a dropping fold to prove check_total fires; scripts/kb gained a KB_MERGE seam so a broken merge can be injected.').
work_done('pr82-fingerprint-guards-2026-09-20',test,'PR #82, follow-up to #76. Added assert!(app.view().prune_empty) to selecting_the_page_already_selected_invalidates_no_cursor (paged_model(false) plus deleted moved-conjunct had left it GREEN); moved its page-selection precondition ahead of the effect and onto App::view(); corrected the test-evidence entry for every_layout_geometry_effect_is_visible_to_the_narrow_fingerprint, whose stated mechanism measurement contradicts, and recorded which two of its four prepare steps are load-bearing. 1425 passing, unchanged. Key is a dated slug and not an improvise- id, because bd issued none: an ID-shaped non-ID is worse than a dead id, the diagnosis being identical and the repair not.').
work_done('pr90-io-gp-correctness-2026-09-21',fix,'PR #90, wave K. improvise-iot6: render_csv built GridLayout::new, which skips prune_empty, so :export and the CLI export path wrote rows AND columns the grid does not draw -- FOURTH instance of the trap and the first a user could see; fixed with with_frozen_records(m, v, None), the PR #78 grid_in fix line for line. A sweep of the remaining GridLayout::new call sites found all four are test code, so this was the last production instance. improvise-zv8t: fit_measure handed preprocess an Overrides::default(), so a numeric- or date-valued document category was inferred rather than forced categorical and the fit refused as an encoding artefact. Two self-review rounds and one adversarial round on the branch; the adversarial round found F2 (forcing by name, budgeting by index), which this PR itself introduced. 1458 -> 1464 passing.').
work_done('improvise-oujb',feature,'Every release now carries improvise-web.tar.gz (the assembled landing page + browser app, one improvise-web/ directory, ~1.6 MB) via [[dist.extra-artifacts]] running scripts/build-web-release.sh. web/index.html gained a build-version span, stamped by web/assemble.sh with the same token as the bundles and verified by the same scripts/assemble-stamp.sh gate (fault-injected: assembly exits 1). The wasm-pack and node pins moved to scripts/web-toolchain-versions.env, read by the ci.yml build job via GITHUB_ENV and sourced by the release script. NOT verified: a real tag push -- and note dist SKIPS build-global-artifacts on a PR run, so CI green on the PR is no evidence about the capture; the substitute evidence is a local dist build --artifacts=global, which did produce the artifact in target/distrib. Also unrun: the Linux toolchain branch of the script.').
work_done('improvise-nn4s',docs,'ADR 004 drafted (context/adr/004-faces-and-themes.md), PROPOSED not accepted -- PR #122, held open because adopting a theming architecture is the user decision. Faces name roles; a theme binds them and is client-local (not a Command, not in ViewState, not projected, not in .improv); inheritance resolves once in improvise-protocol; five portable attributes (fg, bg, weight, slant, underline) and an unrenderable one is ignored. Colour is palette-relative (Default|Named|Indexed) or absolute (Rgb), and the SHIPPED theme is never absolute. Migration needs no flag day because the default theme reproduces today rendering exactly, making each call-site move a TestBackend-verifiable no-op. Format deferred on a stated condition: improvise has no configuration file of any kind, so the first theme file would be the first config file -- a larger decision than theming.').
work_done('improvise-8l0c',bugfix,'formula_panel.rs cut by bytes and subtracted 1 from an unchecked inner.width. REACHABILITY IS WIDER THAN THE TICKET: draw.rs:297 lays the panel out as [Min(40),Length(side_w)] so a terminal at or below ~42 columns gives it width ZERO, and Frame::render_widget has no empty-area guard -- so the crash needed no Unicode at all and resizing was the common path. grid.rs display-width truncate moved to src/ui/text.rs and shared; prefix_by_width replaced a chars().take tail that over-ran by a cell at width 1. Searching for the CLASS rather than the symptom found two more sites placing a string at inner.x + name.len(). Two rival fixes injected and watched fail: a char-count truncator over-runs on wide characters, and repairing only the subtraction still panics at the boundary and at width 1 -- a test written only at width 0 would have accepted it. Filed improvise-n8vh. PR #124, merge e8f66e6.').
work_done('improvise-rw6l',feature,'PR #123, merge 7470b61. Page bar chips became buttons carrying a down-arrow; clicking opens a menu of the axis items and choosing one composes SetPageSelection via a new Click::PageItem (indices, like every existing Click variant, so the page never spells a name). No wire change. The menu is the third keyboard-owning state of the kind editing already was. Codex found a real bug -- the menu branch sat above the metaKey guard, blocking Cmd-R, and Ctrl-j moved the menu two lines below a comment saying Ctrl/Alt are the browser\`s -- fixed by ordering plus one bail, and verified on the merged branch by the orchestrator. Shipped with improvise-771e (browser tile bar reads plain_label) and improvise-wiew (document_keymap).').
work_done('improvise-e0ex',feature,'PR #125, merge 5fdce21. View::new seeds \",.2\"; a FORMAT clause in formula.pest overrides per formula target; FormatPlan in improvise-core replaces the (comma,decimals) pair in every render path; GridLayout::column_key resolves a total format from its own column; web/demo.improv moved to \",.2\". improv.pest untouched. Two Codex findings fixed, both real. Injecting a walker that discards the format clause fails ONE test and passes 242 others in improvise-io -- the measurement that shows raw cannot be the witness. Two fixtures pinned to \",.0\" rather than re-baselined, one of which would have gone VACUOUS WHILE PASSING under the new default. No .improv data line changed. 1654 tests, 37 binaries.').
work_done('improvise-55l5',bugfix,'PR #126, merge 4625593. dispatch None now means no binding matched and nothing else; a construction failure returns a status. Two rivals injected, each failing exactly one of the paired tests.').
work_done('improvise-o9uq',bugfix,'PR #126, merge 4625593. add-record-row places its own cursor after the SetCell; target_row handles the already-exists arm. Reproduced through App::handle_key rather than a hand-placed state: o N o r t h Enter deleted East Revenue cell and renamed East to EastNorth. Not blocked by and does not block improvise-fdk1.').
work_done('improvise-n6t8',feature,'PR #127, merge d46ec59. Every header child that changes with a keystroke is reserved, with #hint the single elastic child; ViewState::status_msg moved to a toast in a fixed #bottom-dock anchored at its bottom so it stacks ABOVE the minibuffer, whose box is byte-identical with and without one. harness/tests/header.spec.js measures the app bar bounding boxes across the NINE modes a browser can reach plus a status message, asserting #mode before each measurement so a key that failed to reach its mode cannot make the comparison vacuous. Three faults injected, all caught. Layout leaves gotcha 48 list of what the harness cannot see; prefers-color-scheme joins it.').
work_done('improvise-bd9o',bugfix,'PR #128, merge 76d909e. Eleven lines: an entrance guard on state.grid.records in edit_or_drill, before cell_at_cursor, plus CellId::Create declining out loud instead of returning None. Reproduced at HEAD -- improvise-55l5 fixed none of it, because Host::handle answers ClientFrame::Key with an empty vec so the key never reaches dispatch. The silence had TWO sources and the ticket predicted one: the coordinate column carries no CellId at all, which is improvise-fdk1 seen from the key path. Four rivals injected; two (mode.is_records in either place) passed the whole workspace at 1659 and are now caught by a deliberately DRIFTED client. Filed improvise-x0xd.').
work_done('improvise-dy3j',bugfix,'PR #129, merge bf83294. A Label category is persisted as a \"kind: label\" entry inside its ## Category: section; grammar first, then format.rs and the parse.rs walker. Closes a P1 where regular + label > 12 was PERMANENTLY UNLOADABLE, because every label reloaded as Regular and the 13th was refused. Second worked instance of doctrine(a_roundtrip_equality_is_blind_to_a_loss_both_sides_share), whose text cites only improvise-349m: the kind was dropped on BOTH sides, so the whole characterization suite was silent. The discriminating rival is a reader that parses the kind and discards it -- the file text is then correct and every output-comparing test passes.').
work_done('improvise-cj4u',bugfix,'PR #129, merge bf83294. View::on_category_added takes a CategoryKind beside the name and both Workbook set_axis corrections are deleted. An injected blanket unseated=true satisfies EVERY label assertion in the repository, so the discriminating controls are that a regular category still takes Row and that a label does not consume the slot the next one wants.').
work_done('improvise-vw47',bugfix,'PR #130, merge 69c873d. A new view takes the first UNUSED \"View <n>\" rather than views.len() + 1; view::unused_view_name in improvise-core is the one derivation and both resolvers call it. Two rivals injected: fixing panel.rs alone fails both_ends on the document text (host writes View 5 with View 4 format intact, client replaces View 4 and resets it), and starting the search at 1 fails the pre-existing incrementally-names test. Baseline measured at 1667 and final 1670 -- exactly the three tests added.').
work_done('improvise-q914',bugfix,'PR #131, merge 9d2bdd3, closing improvise-j6x8 as the same fix. improvise cmd and script now print every non-empty status_msg to stderr, clear it per command, exit 1 on a parse error or an aborted batch, and write the document back only if some command raised dirty (accumulated per command). Three clap doc strings stopped advertising JSON. The injected rival -- print on success, stay silent on refusal -- fails 5 of 12 CLI tests, and a test asking only \"was anything printed\" would pass it. tests/headless_cli.rs is the contract at the process boundary, which exists nowhere below std::process::exit.').
work_done('improvise-jz6w',feature,'PR #134, merge a799e24. KeyOutcome::Request(Request::Save) lets a resolver say \"ask the host\"; Ctrl-S bound through it and removed from the_command_names_that_still_need_the_host_are_these in the same commit. End-to-end guard from a real Client through a real Host to MemoryFs. Three rivals injected and each watched fail. Consequence found and filed rather than patched: Ctrl-S now saves AND opens the browser save dialog, because app.js preventDefaults only unmodified keys -- pre-existing behaviour, newly visible (improvise-clso).').
work_done('improvise-xgra',tooling,'PR #133, merge 22ab58f. scripts/pr-reviews reads review bodies, INLINE comments and issue comments, interleaved in time order; scripts/pr-reviews-test.sh is a fixture-driven check_the_checker with 22 cases in CI kb job. Six faults injected, each caught by the case that names it. Two bugs were found by Codex on the PR and were bash-5-only, invisible on the macOS shell that wrote them. The agent also walked into this PR own trap while building it: pr-watch > f 2>&1; echo EXIT=$? reported 0 over a FAILING kb job, because the echo took the status.').
work_done('improvise-0ygd',bugfix,'PR #135, merge 42ec30d, TUI half only. AdvanceDir::Right judged at_bottom_right from the pre-commit col_count, so Tab on +measure ran AddRecordRow and jumped to (row+1,0) although the commit had just inserted a column at the cursor -- the measure just named left unreachable. Enter escaped only because EnterAdvance aborts at bottom-right and the SetSelected restore at the head of the batch held the cursor. The browser half is worse (the whole command vector is discarded through Upstream) and is UNREACHABLE at this head, because improvise-bd9o declines at the entrance of edit_or_drill -- so no browser regression test can be written before improvise-x0xd.').
work_done('improvise-nxah',bugfix,'PR #136, merge eefd992. The client rolls back its optimistic fold on ServerFrame::Rejected; ServerFrame::Accepted added so an answer addresses a known frame. Five rivals injected, each failing exactly the check aimed at it -- including the single-snapshot design, caught by refusing_one_of_two_frames_in_flight_keeps_the_other. The repro compares against the HOST OWN MIRROR rather than a second client derivation (improvise-vw47 trap) and is total over ViewState rather than a hand-kept field list, asserting the two ends agree BEFORE the refusal so it cannot pass by never having disagreed.').
work_done('improvise-tg27',bugfix,'PR #138, merge 327a76b. header flex-wrap over a breakpoint; main min-width:0; panels capped at 40vw; harness/tests/narrow.spec.js asserts documentElement.scrollWidth <= clientWidth at 360/520/768, so the page own overflow leaves gotcha 48 list of what the harness cannot see. Fault injected by restoring style.css to 22ab58f: 6 of 9 assertions failed, and the two that passed are the two that should. Merge with improvise-nxah verified STRUCTURALLY -- 198 insertions and ZERO deletions across context/, with evidence entries rising 812 to 818 as the independent witness. Found not predicted: the keyboard already reached every clipped column, so what was broken was a FALSE scroll affordance beside a working one.').
work_done('improvise-k844',bugfix,'PR #139, merge 1ae2d2e. importance_ranking sums and divides in f64, narrowing only the bounded share. The injected rival is THE TICKET OWN TITLE -- reassociating to 100.0*(imp/total) in f32 passes the lone-importance and sums-to-100 tests and fails the twin and the guard, 58 passed 2 failed, converting NaN% into a serene 0% for every feature. Scoring bit patterns byte-identical, which is the evidence the fix is confined. context/code/gp.md section 7 corrected in place: it had deferred this bug on a cost that does not exist.').
work_done('improvise-f9qf',tooling,'PR #137, merge a9cad15. scripts/checkers.sh plus scripts/checkers-test.sh (12 cases, each INJECTING rather than asserting). The proof: restoring the multi-assignment local gives 22 passed under the old local gate, 22 passed under direnv, and 2 passed / 20 failed / exit 1 under checkers.sh. The second undetected split is closed -- the four #!/bin/sh and one #!/usr/bin/env sh scripts are now bash, a strict superset needing no porting. Unrun and stated: scripts/build-web-release.sh executes only under dist in build-global-artifacts, which no branch push exercises.').
work_followup('improvise-8tk','improvise-lae','no-file-path UX: prompt for a path rather than only erroring. ExportPrompt is CSV-only, so a new SavePrompt TextEntry mode committing to SaveAs is the likely shape.').
work_followup('improvise-6sq','improvise-ct5','i on an empty records grid reports \'cannot drill\', leaking a pivot-mode concept into records mode').
work_followup('improvise-h0b','improvise-a2x','ViewState field types (AppMode, MinibufferConfig, ViewFrame, KeyCode, KeyModifiers) still derive no Serialize, and improvise-command depends only on serde_json. Deferred: nothing serializes ViewState until improvise-cqi builds a Snapshot, so the derives would have no consumer.').
@@ -423,6 +498,27 @@ decision('improvise-ni6k',cursor_invalidation_is_observed_not_declared,'No effec
decision('improvise-864a',pages_artifact_reaped_by_a_foreign_successful_deploy,'Measured over 100 push-to-main runs 2026-09-19..2026-09-21: 12 of 12 artifact-not-found deploy failures had ANOTHER run SUCCESSFUL Pages deployment complete inside their own upload-to-deploy window, against 2 of 56 successes. Direction proved by the oldest run of the 17:47Z batch failing BEFORE the next deploy started and before any deployment existed, since deploy-pages fetches artifact metadata before creating one. NOT queue depth, NOT supersession, NOT retention, NOT the action deleting it. PR #46 tip-of-main guard aimed at the right quantity for the WRONG REASON and still must not ship: the association is not a mechanism, and 5f871f7 survived a foreign deploy inside its own window. Reproduce deliberately before fixing.').
decision('improvise-zv8t',a_document_category_is_never_inferred,'fit_measure now builds its Overrides from the same header[..category_columns] slice it already handed Features::of, forcing each to Forced::Categorical, so the one place the document own answer was known is the only place either caller reads it from. Closes Artefact::NumericCategory and DatePart-over-a-category from the only production entry point. The Artefact arms STAY: crate::dataset is pub and takes the caller Overrides, so a caller that builds one without forcing still produces a Numeric role over a category column -- deleting the arm would derive the classification from one caller habits rather than from the roles. Cost accepted and filed as improvise-e8no: a wide numeric category now one-hots past MAX_FEATURES and refuses a fit that used to ignore it. Do NOT close e8no by making the forcing conditional on cardinality -- that is inference by another name, and makes the ENCODING depend on a budget rather than on what the document says.').
decision('improvise-e5pf',the_mode_settles_where_the_grid_does,'resolve::changed_mode is DELETED. Every composition site emits the plain AppMode::Normal, and ClientState::fold settles against the grid it HOLDS via improvise_protocol::settled_mode -- one rule, also called by ClientSession::fold and App::settle_normal_mode, with App::normal_mode deleted as its host-side duplicate. Composition-time settling was PREDICTION: switch_view_at composes [SwitchView(target), ChangeMode(Normal)] and read the OUTGOING grid. Read-time derivation was rejected for having the same window and for moving the enumeration from composition sites to READERS, where a missed reader is silent. settled_mode settles the Normal pair ONLY -- Editing/RecordsEditing pass through -- which is why a records decision must ask the grid and never the mode. Same shape the repo already prefers: cursor_epoch over matching on CycleAxis, document_generation over matching on LoadModel, the dirty flag over a table of command variants.').
decision('improvise-oujb',pages_tracks_main_release_captures_a_copy,'User ruling 2026-09-22: \"I like Pages showing the latest main for now.\" So pages-deploy is unchanged -- still push to refs/heads/main -- and a tag CAPTURES the assembled site as improvise-web.tar.gz rather than deploying one. The artifact is a self-hostable copy and a rollback target, NOT what a visitor runs; README says so, because a reader who assumes otherwise is wrong in a way that costs an afternoon. The divergence between a binary user and a site visitor is now deliberate, which is what makes the build-version stamp load-bearing rather than cosmetic. \"For now\" is recorded deliberately: a current preference, not a permanent architecture decision.').
decision('improvise-oujb',extra_artifacts_not_a_hand_edit,'release.yml is autogenerated by cargo-dist and a hand edit is clobbered by the next dist init, so the web capture is [[dist.extra-artifacts]] in dist-workspace.toml. Verified against dist 0.30.4 -- the pinned version, downloaded, because dist REFUSES to run at all on a cargo-dist-version mismatch so a 0.32.0 test would have proved nothing: dist plan lists improvise-web.tar.gz and dist generate leaves release.yml byte-identical. The build command calls web/build.sh and web/assemble.sh rather than assembling its own way, which is what the ci.yml build job comment exists to protect.').
decision('improvise-n6t8',reserve_space_do_not_fit_it,'User ruling 2026-09-22: \"As a general rule, there shouldnt be motion of layout elements I expect to be fixed.\" So an element whose CONTENT varies while its POSITION is read as fixed must have its space RESERVED rather than fitted to the text. In the browser header that covers the mode pill (AppMode::label -- NORMAL, CATEGORIES, FORMULA all differ in width, so every mode change shifts everything after it), the hint line (one per mode, reflowing on the same event and compounding it), the status line, the document title and the view chip. It is also the reason a transient message must be a toast that is OUT OF FLOW rather than merely short: shortening reduces the motion and does not remove it. The fragile fix is a hardcoded min-width sized by eye to todays longest label, which silently resumes the jitter the day a longer AppMode variant is added -- the compiler forces label() to be filled in for a new variant and nothing checks that the CSS still fits it, so the guard must be a rendered-layout assertion that walks the modes, with a too-long label injected to prove it discriminates.').
decision('improvise-pv3g',guard_the_derived_quantity_not_the_inputs,'A finite f32 target can still overflow the spread: standardize sums (t-mean)^2 in f32, so eight rows at a 1.5e20 spread give 1.8e40, past f32::MAX, and std becomes inf -- after which denormalize computes 0.0*inf + mean = NaN, surfacing two crates away in improvise-gp number_text. fit_measure checked every INPUT and saw nothing, because every input was finite. The fix makes the poisoned map unrepresentable rather than validated: Scaling has private fields and one constructor refusing a non-finite part, standardize returns Option, and cv_regress pools nothing for a fold it cannot standardize -- which closes the SILENT half, where the same overflow corrupted R-squared with no panic to announce it. Note the crashing spread SHRINKS as records are added, so guarding the inputs is guarding the wrong side.').
decision('improvise-x0dc',panel_open_is_not_panel_focus,'RECOMMEND DECLINING as written, pending a user ruling; left open. Esc from a panel mode leaves the panel OPEN, which reproduces, but is deliberate: category_panel_open starts TRUE in Normal mode, so an open panel in Normal is the state the app BOOTS IN -- if that were the bug, the app would boot in the bug. Three independent corroborations: F/C/V are toggle-panel-and-focus while Ctrl-F/C/V are a separate toggle-panel-visibility; Tab in Normal is cycle-panel-focus, meaningless unless panels stay open unfocused; and the panel hint spells enter-mode normal as \"back\", not \"close\". The genuine defect its own evidence points at is improvise-df2w: render.rs formula_rows returns an empty vec when closed and app.js paintPanel hides on rows.length === 0, so an OPEN BUT EMPTY panel is invisible in the browser while the TUI draws its empty message -- two answers to \"is this panel visible\".').
decision('improvise-wiew',keymap_layer_named_for_its_subject,'document_keymap sits between global and Normal / the three panels / TileSelect; RecordsNormal reaches it THROUGH Normal; Help, ImportWizard and the text-entry modes take global directly. The orchestrator framed the split as \"is the document the subject right now\", which is a judgement; the implementing agent found a STRUCTURAL line instead: every key the global binds is a UI action, whereas page-next resolves to SetPageSelection, which is EffectKind::Model(LayoutGeometry) -- the first shared keys that MUTATE. Under the help overlay that is an invisible change to the grid it covers, so the rule is \"may a fallthrough key mutate the document\" rather than taste. The NAME is the only guard on membership, since nothing in the code stops a mode parenting the layer: document_keymap, never common or shared. The layer owes its own Char-only constraint for the identical fallthrough reason as the global, written down BECAUSE no descendant binds AnyChar today.').
decision('improvise-e0ex',decimals_by_default,'View::new seeds \",.2\". A ratio under \",.0\" draws as 1, and a format cannot be guessed from a value, so noise on whole numbers (18,400.00) is the cheaper error. The override is spelled IN THE FORMULA per the user ruling -- a formula target IS a _Measure item (gotcha 7), so per-formula and per-measure differ only for a measure no formula owns, and that is raw data whose format is the view job. improv.pest is UNCHANGED: formula_raw already carries the line verbatim, so format_clause lives in formula.pest and the grammar-walking generator sees no new spelling. sp+ and not a lookahead, for WHERE reason -- the generator skips predicates.').
decision('improvise-o9uq',order_is_the_fix,'add-record-row appends viewport_effects(target_row,0,..) AFTER the SetCell. The order is the entire fix: the same two effects the other way round are the bug verbatim, because the frame observation inside apply_effects invalidates a cursor placed before the append. The acceptance criteria said \"append viewport_effects\" and did not say where, and the two orders are indistinguishable by reading -- so the injected rival is the SWAP, not the omission. target_row is row_count() unless the record already exists, in which case it is that record index: predicting row_count() unconditionally puts the cursor one past the end in that arm.').
decision('improvise-n6t8',two_status_sinks_rather_than_one_composed_string,'The transport fact and the document fact go to two ELEMENTS, not to one string joined with a separator. composeStatus is deleted. It had kept gotcha 46 by CONVENTION -- a repaint that wrote the whole string could erase a connection error, and the join was what stopped it; two sinks make it STRUCTURAL, because a repaint reaches createShell onModel and cannot address onShell at all. The join unit test was REPLACED and not dropped (\"a repaint model status cannot reach the transport sink at all\"): a separation with no guard is how one regresses into a single sink silently.').
decision('improvise-dy3j',absence_is_the_compatible_reading_of_a_new_format_line,'kind_kw spells only \"label\" and format_md emits the line only for a CategoryKind::Label, so every file written before the line existed parses unchanged and every pivot category section is byte-identical -- no migration and no moved round-trip expectation. parse_kind_kw is an exhaustive Result-returning match rather than a bool read off the rule PRESENCE, so a future second keyword fails at the walker instead of being read as \"label\" because it arrived on a kind_line.').
decision('improvise-vw47',unused_name_helper_not_a_create_view_refusal,'Workbook::create_view REPLACES on purpose and must not be touched. Verified: it has exactly two production callers -- ui/effect.rs:310 inside effect::CreateView, and persistence/parse.rs:419 guarded by contains_key so it cannot observe replacement at all. The _Records/_Drill rebuilders are NOT callers; they reach it through effect::CreateView, emitting a fixed name on every entry, which is the SAME effect the user-facing path uses -- so the effect cannot distinguish them and the name must be fixed at the RESOLVER. Refusing or renaming inside create_view turns \"a view was silently reset\" into \"the key does nothing\", a worse bug wearing a better error message. view::unused_view_name takes NAMES rather than a Workbook because the browser holds only ViewListCache, and project::view_summaries maps workbook.views one-for-one including virtual views, so both ends see the same list.').
decision('improvise-z05h',the_registry_names_the_refusal_not_the_validator,'require_args drops its \`word\` parameter and CmdRegistry::parse prefixes \"{typed}: \", so a refusal names what the USER TYPED rather than what the validator was called about. That deletes 41 command-name literals from cmd/ -- the same argument gotcha 15 makes about registry.rs, where every register* call reads its name off a prototype so a typo is impossible. \`interactive\` deliberately does NOT prefix, because dispatch already does since PR #126 and both would double it.').
decision('improvise-jz6w',key_outcome_request_is_narrow,'KeyOutcome::Request carries a narrow Request enum (Request::Save, with Request::frame the one mapping to an envelope) and NOT a ClientFrame. Two reasons, the second serious: an arbitrary frame lets a resolver compose a Viewport, whose extent is the PAGE s fact and reaches no derivation from a keymap; and it lets a resolver compose a Commands batch, which is INDISTINGUISHABLE ON THE WIRE FROM A Local while skipping the local fold Local s contract requires -- a desync with nothing to observe it. Guarded rather than asserted: \`requested\` is checked first in step so no name sits in two lists, and a request cannot travel inside a Binding::Sequence (a sequence is one frame), with no_binding_puts_a_request_inside_a_sequence keeping that refusal arm unreachable rather than untested. Not a Command: gotcha 45 reason holds.').
decision('improvise-xgra',an_inline_comment_at_the_head_satisfies_the_wait,'A pulls/{n}/comments entry carries a commit_id, so it is a claim about WHICH TREE WAS READ, and it is exactly the case that failed on PR #122. The clincher is that GitHub \"Add single comment\" CREATES A REVIEW WHOSE BODY IS EMPTY -- under the old rule that reviewer exit code was right while their words were invisible, so the surfaces and the wait had to move together or the tool would be honest and useless. An ISSUE-LEVEL comment does NOT satisfy it: no commit, so no claim about a tree, and \"I am rebasing shortly\" satisfying a wait for a review is the widening that looks like the fix. There is a fixture and an injected fault for exactly that.').
decision('improvise-0ygd',split_on_the_buffer_not_the_column,'In records mode +measure is ALWAYS the rightmost column, so cursor.col >= col_max names that one cell and nothing else -- which means the entire \"Tab at the bottom-right starts the next record\" bulk-entry contract is reachable ONLY from that cell with an EMPTY buffer. A guard keyed on the column alone fixes improvise-0ygd and SILENTLY DELETES BULK ENTRY. The injected rival proves it: dropping the value-awareness passes the new test and fails tab_on_an_empty_new_measure_column_still_starts_a_record_row. When one cell is the only member of a class, a guard on the class is a guard on the cell.').
decision('improvise-nxah',a_refused_frame_is_rolled_back_by_the_fold_not_a_cache,'What rolls back on ServerFrame::Rejected is ViewState -- the thing the fold produces -- and never a cache, because a Command::Model is A NO-OP IN reduce_view: the optimistic fold never touches a cache at all, and page_coords moves only under Projection::Grid. That single fact refutes the bug report own evidence (which blamed page_coords) and dissolves the wave recorded contradiction between nxah and improvise-3al1 -- the staged typing 3al1 needs rides in SetBuffer frames, which carry no model command, are never refused, and have already settled, so rolling back the COMMIT restores the buffer TO THE TYPING. Only a frame carrying a Command::Model is rejectable, stated exhaustively by Checked::against.').
decision('improvise-tg27',a_reserve_is_a_floor_and_must_reflow_not_widen,'header{flex-wrap:wrap} over a @media breakpoint: a flex item wraps exactly when the line cannot hold it, so the desktop bar is untouched BY CONSTRUCTION rather than by a chosen number -- no magic width to drift. Plus main{min-width:0}, 40vw caps on the side panels, and max-width caps so a reserve cannot exceed its own line. The reflow threshold is deliberately asserted NOWHERE, because #status composes the DOCUMENT NAME: the same 1024px window is one line under a short name and two under a long one. Only 1280 is pinned, being where improvise-n6t8 stability spec runs.').
decision('improvise-f9qf',one_checker_entry_point,'scripts/checkers.sh is the ONE entry point for every check_the_checker, called verbatim by CI and by the local gate so there is no second invocation to drift. It pins a bash >= 5 (ambient first, nix only when the ambient one is older, exit 3 when neither), SCRUBS THE ENVIRONMENT to an allowlist (env -i plus PATH/HOME/TMPDIR/NIX_*/GITHUB_*, extensible via CHECKERS_KEEP), and refuses any shell shebang in scripts/ that is not #!/usr/bin/env bash. The pinned bash goes on the FRONT of PATH so scripts the suite invokes get it too -- which is where the fault actually lived. CI logs \"checkers: bash 5.2 at /usr/bin/bash\": ambient, no nix, zero startup.').
decision('improvise-f9qf',nix_shebang_declined_on_availability_not_speed,'Measured warm, 20 invocations: ambient bash -c true 5.1ms; nix shell nixpkgs#bash --command 61.8ms; nix-shell -p bash --run, THE MANPAGE FORM THE USER POINTED AT, 675.2ms -- 132x, and that inside git merge machinery for beads-merge-driver.sh. But the DECIDER is availability: nix is installed on ONLY the kb job. build, test, wasm, js, harness-lint, browser and pages-deploy have none, so a nix shebang needs install-nix-action added to three more jobs, paid every run, to reach a bash those runners already ship. zsh declined too -- not a shebang change (word splitting, 1-based arrays, globbing), sixteen scripts to read, and no zsh on the runner either.').
decision('improvise-6uva',script_replay_lost_its_context_at_35946af,'improvise script and improvise cmd resolve every command through CmdRegistry::parse -- the PURE arm, which builds CursorState::default() (0,0,0,0) -- never the interactive arm that builds CursorState::from_ctx. So every Move command and enter-advance snapshots a ZEROED cursor AT PARSE TIME, clamps to (0,0) and emits SetSelected(0,0): worse than a no-op, it RESETS the cursor, and no cursor-reading command is reachable from a script. Broken by 35946af (2026-04-04), a performance refactor introducing the register/register_pure/register_nullary split, FIVE MONTHS BEFORE the first wasm-client commit -- so the web refactor is innocent. Both directions demonstrated: at the parent 649d80c a move-then-clear cleared row 2 and a bare clear cleared row 0; at HEAD both clear row 0. THE SHAPE: a registry with two construction arms silently demotes every caller of the weaker arm, and nothing type-checks which arm a caller deserves.').
decision('improvise-gd28',browser_drill_blocked_by_surrogate_scope_not_layer_2,'The ticket recorded drill as blocked on ADR 001 layer 2 because ApplyAndClearDrill is session-local and unaddressable. That reasoning is about the COMMIT path. Entering a drill needs no commit, and CreateView/SetAxis/SetPageSelection are already unaddressed ModelCommands that NAME THEIR VIEW. What a browser cannot supply is the OPERAND -- CellId::Aggregate(AggregateHash), an opaque u128, which is ADR 002 working as designed. The real gap is one level down from where it was looked for: THE MUTATION AND ITS OPERAND HAVE DIFFERENT SCOPES. The mutation configures the shared _Drill and is addressed to nobody; the operand exists only inside one session layout. So the fix is not a new mechanism but the view:String convention every other view-scoped variant already follows, resolved against a layout derived for that name -- no SessionId needed. fdk1 is also not needed: edit_or_drill declines records-mode editing OUT LOUD before reaching a cell, so a browser cannot edit inside a drill grid at all and no divergent staging scheme is reachable.').
wire_carries(command,both_directions,'user intent up, projection commands down').
wire_carries(snapshot,on_connect_only,'improvise-q08: full ViewState + render cache filled from current viewport').
wire_carries(command,both_directions,'user intent client->server, projections server->client').
@@ -565,6 +661,22 @@ kb_issue_found(ticket_premise_false_8uz,'improvise-8uz asserted selected/row_off
kb_issue_found(ticket_premise_false_rml,'improvise-rml called category_panel.rs an \"exact copy\" of tile_bar.rs. It was not: the colors were identical but the labels differed (\"Row ↕\" vs \"Row\", \"None ∅\" vs \"·\"). Only the color table was true duplication; the four label sets are four deliberate vocabularies and were preserved as four methods.').
kb_issue_found(duplicated_active_view,'adding ViewState::active_view created a second copy of \"which view is active\". The improvise-8uz characterization test caught the desync immediately because it called workbook.switch_view directly. Held in lockstep by two writers until improvise-jytm, which made the divergence deliberate: the guard test the_two_active_view_names_never_diverge was REPLACED, not deleted, by the_documents_default_view_outlives_this_clients_switches and a_fresh_session_opens_the_documents_default_view -- a deliberate split with no guard is how one silently regresses into the other.').
kb_issue_found(copilot_stopped_reviewing_this_repo,'Measured 2026-09-20: PRs #12, #21 and #27 carry 2, 8 and 4 copilot-pull-request-reviewer[bot] reviews; #61, #66, #68 and #70 carry none. Both request paths report success and register nothing -- the GraphQL requestReviews mutation with botIds BOT_kgDOCnlnWA returns clientMutationId null, and POST /pulls/<n>/requested_reviewers with copilot-pull-request-reviewer[bot] returns 200 with requested_reviewers empty -- and no review_requested timeline event appears. Cause unknown; something changed between #27 and #61. Consequence for the process: AGENTS.md said \"merge only once Copilot passes\", which is an instruction to wait forever on a review that was never requested, since an ABSENT review and an UNREQUESTED one look identical from outside. That is doctrine(review_is_requested_not_inherited) seen from the requesters side, and doctrine(an_advisory_gate_is_not_a_gate) with the gate removed rather than never added. AGENTS.md now says review is an adversarial agent the orchestrator dispatches, naming the head SHA, and keeps the Copilot mechanics for the day it returns.').
kb_issue_found(github_build_setup_injects_into_the_wrong_job,'dist github-build-setup is documented as running extra steps before dist own build step, and is the obvious way to give a release job a wasm toolchain. Measured on dist 0.30.4 by injecting a named probe step and regenerating: it lands in build-local-artifacts ONLY -- the four-platform matrix job -- and never in build-global-artifacts, which is where a GLOBAL [[dist.extra-artifacts]] is actually built. So it cannot provision anything for a global extra artifact, and the provisioning must live inside the build command itself. The hook reports success and simply lands elsewhere: nothing distinguishes \"injected somewhere useless\" from \"not injected\", which is doctrine(an_empty_actions_log_is_retention_not_history) in its configuration form. NOTE ON CITING: the proposing agent named that doctrine from memory and invented a plausible key for a fact that exists under a different one -- the kb form of the dead-ticket-ID hazard. scripts/kb refused the delta over it, which is the gate doing its job; grep a key before citing it.').
kb_issue_found(minibufcolor_roles_are_not_what_they_look_like,'MinibufColor Green/Cyan/Yellow was read by the orchestrator as editing/destructive/search. BOTH HALVES ARE WRONG at the eight MinibufferConfig construction sites. Cyan is the FORMULA prompt; there is no search minibuffer at all, SearchMode being Normal plus a flag (gotcha 14) with the search MATCH drawn Black-on-Yellow in the grid. And green/yellow does not split on editing-vs-prompting: item_add is GREEN while category_add is YELLOW, the same act -- typing a name into the document -- on opposite sides. Consequence for improvise-nn4s: its own cheapest proposed step, renaming the three variants to roles, CANNOT BE DONE HONESTLY, because no three-way split fits eight sites; the honest move is a face per text-entry mode, eight faces with three resolving alike today. The general point: a colour-named enum does not merely fail to be themeable, it actively MISLEADS the next reader about what the roles are -- the orchestrator read roles off the colour names and was wrong twice in one sentence.').
kb_issue_found(stale_count_in_tui_md_default_chrome_arm,'context/code/tui.md line 125 says mode_style spells out \"the fourteen variants that take the default chrome\". The arm lists THIRTEEN -- verified by the orchestrator against src/draw.rs: Normal, RecordsNormal, FormulaEdit, FormulaPanel, CategoryPanel, CategoryAdd, ItemAdd, ViewPanel, ImportWizard, ExportPrompt, SavePrompt, Help, Quit. 13 + Editing + RecordsEditing + CommandMode + TileSelect is the 17 AppMode variants, so the arithmetic confirms thirteen. Nothing checks a count written in prose, which is why it survived; the exhaustive match itself is sound and only the sentence about it is wrong.').
kb_issue_found(twelve_sites_hardcode_black_ink_on_an_accent,'Twelve TUI sites hand-roll reverse video as fg(Color::Black).bg(<accent>) -- mode_style in src/draw.rs is one, visible in full. Black ink is hardcoded rather than being the terminal background, which is a latent bug on a light terminal: the accent is palette-relative and survives the user theme, the ink does not. Found while deriving the face vocabulary for ADR 004, which raises it as open question 3: whether reverse is a portable attribute (honest for these twelve, but makes resolution stateful for CSS, Swift and Qt) or whether the answer is a named ink face.').
kb_issue_found(five_tickets_closed_by_one_unrecorded_commit,'7a44616 (\"fix(panel): five category-panel and axis defects, four of them P1\") closed improvise-uej8, improvise-3b2b, improvise-g3no and improvise-koh6, and ALL FOUR were still OPEN in bd when agents were dispatched at them -- two of them costing a dispatch each. The commit message says \"five defects\" and names none of the ticket ids in a form bd could act on. A commit that closes several tickets should cite every id, because the export is the only thing a later reader greps; see also doctrine(a_ready_ticket_may_already_be_fixed). One misreading compounded it: an earlier agent wrote that a command \"left the host-needed list WITH improvise-koh6\", which reads as context rather than as the close it actually was.').
kb_issue_found(the_compounding_header_element_was_status_not_the_mode_pill,'improvise-n6t8 was filed naming the mode pill, following the user report. The spec failed first on the header HEIGHT: #status was flex: 0 1 auto beside a flex: 1 1 auto hint, so a longer hint took the shrink out of #status, which wrapped to two and then three lines -- 52.5px to 71.0px. THE OLD HEADER WAS TWO LINES TALL AT NORMAL WITH NO MESSAGE AT ALL. Reserving the pill alone would have left the bar moving and the PR would have shipped looking correct. Fixed with flex: 1 1 0 on the hint and nowrap plus flex: 0 0 auto on #status. The lesson is about the ORDER of work: writing the measurement first is what found an element neither the reporter nor the ticket named.').
kb_issue_found(the_edit_that_created_the_drift_was_the_one_that_added_the_third_place,'improvise-bd9o added a new section to context/code/clients.md, and ChatGPT Codex caught two sibling passages the new section falsified. gotcha 48 says a fact stated in more than one place is changed by a grep and not an edit -- and this is its sharpest form: WRITING A NEW SECTION IS EXACTLY WHEN ITS SIBLINGS STOP BEING SEARCHED FOR, because the author is composing rather than amending and has no edit site to search from. The same session produced two other instances: a gotcha-38 correction whose own arithmetic went stale, and a release document whose tag inventory was wrong within hours.').
kb_issue_found(generator_coverage_of_a_new_production_is_checkable_and_unchecked,'context/code/formula-and-io.md says plainly that a new .improv production needs grammar-generator coverage or the proptests quietly stop describing the format -- and nothing measures it. improvise-dy3j PROBED: 14 of 500 generated files carry the new kind: label line and all 14 parse to a CategoryKind::Label, about 7 per 256-case run. Genuine coverage, and free because kind_line is a category_entry alternative. The probe was REMOVED rather than kept, because its rate depends on proptest RNG and as a standing assertion it would fail on a dependency bump for an unrelated reason -- the measurement lives in the argument where it can be re-run. A one-line --probe mode on the existing generator would make this a habit rather than something an agent happened to do.').
kb_issue_found(the_bug_lived_underneath_a_test_that_named_it,'create_and_switch_view_names_incrementally has asserted \"the name follows the workbook\" since improvise-aaw, and improvise-vw47 lived underneath it for that whole time: every fixture the test uses leaves len + 1 FREE, so it could not separate \"the count\" from \"the first unused name\". It did earn its keep at the fix -- the rival that starts the search at 1 fails it -- but a test whose fixtures never exercise the distinction its NAME claims is a test that will not notice the distinction breaking. When adding a fixture to an existing test, ask which of the properties in its name the new fixture can actually separate.').
kb_issue_found(a_new_context_doc_must_be_added_to_the_doc_site_sections,'scripts/build-doc-site.mjs (improvise-res9) requires every context/**/*.md to be either published or an explicitly named omission, and it is reachable ONLY through web/assemble.sh in CI -- so a new context/ document fails the \`Build all bundles\` job with \"neither published nor a named omission\", a gate that appears in no brief and in no local gate list. ChatGPT Codex found the same thing about four minutes after the push. Worth adding to the standard brief: a new context/ doc needs a SECTIONS entry. Noted in passing: context/code/doc-site.md was itself already stale in two counts (\"the seven context/code/*.md documents\" where twelve exist, and \"501 entries\" where the checker reports 804), both de-numbered rather than re-asserted.').
kb_issue_found(a_storage_assertion_cannot_separate_reaching_from_reaching_wrongly,'improvise-jz6w injected Request::Save => ClientFrame::Commands(vec![]) as a rival. The end-to-end test asserts the document comes back off storage -- and an EMPTY BATCH WRITES NOTHING EITHER WAY, so the storage assertion alone cannot separate \"reached the host\" from \"reached the host as the wrong kind of thing\". Asserting the FRAME is the discriminator. The general form: when a test asserts a downstream EFFECT, ask which wrong upstream values produce the same effect, and assert the value where they are not distinguishable by the effect.').
kb_issue_found(repo_map_said_clause_four_was_undelivered_for_a_day_after_it_landed,'improvise-yqpd delivered ADR 001 clause 4 on 2026-09-21 -- App::snapshot/restore plus a Sessions clone in Host::mutating_frame, restored by undo_frame -- and context/repo-map.md gotcha 47 went on saying \"clause 4 is not delivered\" afterwards. The orchestrator then COPIED that claim into an agent brief as a live constraint, and the agent caught it by reading the tree. Verified: mutating_frame, undo_frame, App::snapshot and App::restore all exist and improvise-yqpd is closed. Corrected, with the correction recorded in the gotcha itself so the next reader sees that the clause list is a claim to check rather than a fact.').
kb_issue_found(a_layout_measured_before_the_status_settles_is_not_the_layout,'improvise-tg27 first claimed its fix made #hint legible, and its own spec ASSERTED THAT AND PASSED -- because it measured immediately after the grid painted, before #status had composed the transport fact: 186px where the settled value is 1px. The spec now waits for #status before measuring anything. The general form: a page that composes a line from two asynchronous facts has an intermediate layout that is real, renderable and WRONG, and a measurement taken there is a measurement of a state no user sees for more than a frame.').
kb_issue_found(check_evidence_cannot_see_a_templated_test_name,'scripts/check-comment-runs.py --check-evidence scrapes LITERAL test(\"...\") strings out of *.spec.js, so a Playwright test named by a template literal -- test(\`at ${width}px ...\`) -- is invisible to it and its evidence heading reports as an orphan. improvise-tg27 hit this and restructured to five literally-named tests, each looping widths internally with the width in the assertion message. Parameterise INSIDE one literally-named test rather than looping test().').
kb_issue_found(k844_description_was_verbatim_e8no_body,'improvise-k844 stored description was a verbatim copy of improvise-e8no body -- the wide-numeric-category budget -- contradicting its own title. Confirmed by diffing the two DESCRIPTION blocks: identical. The agent worked the TITLE, which was right, and the defect had one good consequence: reading e8no text while working k844 is what made it check whether e8no was done, and e8no turned out to be STALE-OPEN with both its guard tests passing. A ticket carrying the wrong body is worse than an empty one, because an agent that trusts it works the wrong problem while believing it has a specification.').
kb_issue_found(the_old_checker_gate_overrode_every_shebang,'The gate in the orchestrator brief -- \`for t in scripts/*-test.sh; do sh \"$t\"; done\` -- OVERRIDES EVERY SHEBANG with /bin/sh, so on macOS it ran every suite under bash 3.2 in sh-mode regardless of what the file declared. The gate was the disease it was meant to catch. Seven further sites ran a repo script through an explicit \`sh\`, and ONE OF THEM IS dist-workspace.toml, which a first-line shebang gate STRUCTURALLY CANNOT SEE: the shebang rule closes DECLARATIONS and never INVOCATIONS, and that one was found by grepping for the CALL after fixing the declaration, not by the gate going green. Also: scripts/node-test.sh matches *-test.sh and is a test RUNNER, so a naive glob ran it with no arguments and it printed its usage INSIDE A GREEN GATE -- the rule is now that a suite is X-test.sh where X or X.sh exists beside it.').
functional(invariant/2).
functional(location/3).
functional(doctrine/2).
@@ -673,3 +785,4 @@ citation_resolves(decision,A):-decision(B,A,C).
citation_body(A,B):-sub_atom(A,C,1,D,')'),!,C>0,C<200,sub_atom(A,0,C,E,B).
trim_left(A,B):-sub_atom(A,0,1,C,' ')->sub_atom(A,1,D,0,E),trim_left(E,B);B=A.
decision_name(A,B):-citation_body(A,C),sub_atom(C,D,1,E,','),!,F is D+1,sub_atom(C,F,G,0,H),trim_left(H,B).
work_done('improvise-gd28','Virtual-view shape extracted to improvise-core as ViewStep/records_spine/records_steps/drill_steps; cmd/grid.rs keeps only steps_into_active_view, twenty lines of effect translation. No test expectation moved and none was added -- the existing drill tests (Filter placement asserted on the effect Debug, per-view cursor, formula drill) already pinned it. PR 140.').