Files
improvise/prolog-sessions/kb.pl
T
Edward Langley 755e3477c5
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): drill decomposition, aggregate scope, and a closed ticket read as open
PRs #140/#141. Gotcha 27 gains the one home for what a virtual view must BE
(`view::{records_spine,records_steps,drill_steps}` as data; `cmd/grid.rs::
steps_into_active_view` as the TUI's translation). Gotcha 44 gains the
finding: `RecordHash` is document-scoped and `AggregateHash` is layout-scoped
and cannot be otherwise, so `App::resolve_aggregate` answers only where the
host stands — that, not ADR 001 layer 2, is what blocks browser drill.

Gotcha 29 described improvise-lltt as an open gap. It is CLOSED, and verified
so: every view-scoped `ModelCommand` carries `view: String` and `model_effects`
maps it to `ViewTarget::Named`. A closed ticket cited as an open gap is the
mirror of a dead ID — it reads as a promise that work remains.

improvise-gd28 closed by REMOVING its dependency on improvise-7b8v rather than
forcing past it: the dependency encoded the premise #141 disproved. Files
improvise-t0ma, improvise-obtu, improvise-t219.

kb_consistent: TRUE, 788 facts. Dead-ID audit: 360 ids, all resolve.

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

789 lines
315 KiB
Prolog

crate(improvise_core,'crates/improvise-core','Model, View, Workbook, formatting; no UI/IO').
crate(improvise_formula,'crates/improvise-formula','formula parser, AST, parse_formula').
crate(improvise_io,'crates/improvise-io','improv save/load, CSV/JSON import; no UI/commands').
crate(improvise_command,'crates/improvise-command','command/effect layer + semantic App state + Sessions + Host; no ratatui').
crate(improvise_main,src,'ratatui TUI rendering, draw loop, clap CLI').
crate(improvise_protocol,'crates/improvise-protocol','wire vocabulary + portable session slice: Command, ViewState, per-widget caches folded from Projection events, bootstrap-only Snapshot, reduce_view, ClientFrame/ServerFrame, Basis').
crate(gp_tree,'crates/gp-tree','genetic-programming tree fitting; problem-agnostic, serde only. Owns the data module (preprocess, encode_row, read_csv) and the vendored SplitMix64.').
crate(improvise_gp,'crates/improvise-gp','bridge: emits a fitted gp-tree Tree as an improvise formula string and validates it through parse_formula. Exists because gp-tree must stay problem-agnostic and improvise-formula must not drag GP into the wasm bundle.').
crate(improvise_ws_server,'crates/improvise-ws-server','the socket host: tokio + tungstenite transport and a Registry of Hosts keyed by DocumentId. Re-exports Host and the frames; defines neither. Native only.').
crate(improvise_wasm_client,'crates/improvise-wasm-client','the browser client: the fold, the key classifier, and the render model the DOM paints. Non-dev deps are improvise-protocol + serde + wasm-bindgen and nothing else.').
crate(improvise_worker_server,'crates/improvise-worker-server','the other host: the same App, Sessions and Host as the socket server, behind self.onmessage/self.postMessage with OPFS underneath. worker.rs and vfs.rs are ordinary Rust and hold every decision and every test; bindings.rs is the wasm32 shim, run by harness/tests/shell.spec.js under Chromium and by no Rust test.').
layer(model,improvise_core).
layer(view,improvise_core).
layer(formula,improvise_formula).
layer(io,improvise_io).
layer(command,improvise_command).
layer(rendering,improvise_main).
layer(protocol,improvise_protocol).
layer(fitting,gp_tree).
layer(fitting_bridge,improvise_gp).
location(add_keybinding,'crates/improvise-protocol/src/keymap.rs','Keymaps::default_keymaps()').
location(add_command,'crates/improvise-command/src/command/cmd/registry.rs','default_registry()').
location(add_effect,'crates/improvise-command/src/ui/effect.rs','impl Effect').
location(formula_eval,'crates/improvise-core/src/model/types.rs','eval_formula / eval_expr').
location(cell_storage,'crates/improvise-core/src/model/cell.rs','DataStore').
location(category_behavior,'crates/improvise-core/src/model/category.rs','Category').
location(view_axis_logic,'crates/improvise-core/src/view/types.rs','View').
location(grid_layout,'crates/improvise-core/src/view/layout.rs','GridLayout').
location(improv_format,'crates/improvise-io/src/persistence/improv.pest','PEG grammar, source of truth').
location(number_display,'crates/improvise-core/src/format.rs',format_f64).
location(cli_args,'src/main.rs',clap).
location(import_logic,'crates/improvise-io/src/import/wizard.rs','ImportPipeline').
location(frame_layout,'src/draw.rs','draw()').
location(app_state_modes,'crates/improvise-command/src/ui/app.rs','App, AppMode').
location(wizard_steps,'crates/improvise-io/src/import/wizard.rs','ImportWizard advance/previous/build/validation_error').
location(wizard_keys,'crates/improvise-command/src/command/cmd/wizard.rs','step_effects per-step key tables').
location(wizard_render,'src/ui/import_wizard_ui.rs','ImportWizardWidget').
location(save_effect,'crates/improvise-command/src/ui/effect.rs','Save, save_to_current_path').
location(abort_chain,'crates/improvise-command/src/ui/effect.rs','AbortChain').
location(apply_effects,'crates/improvise-command/src/ui/app.rs','App::apply_effects').
location(sequence_dispatch,'crates/improvise-command/src/command/keymap.rs','Keymap::dispatch, effects_of, Binding::Sequence flattening').
location(text_entry_builder,'crates/improvise-protocol/src/keymap.rs','TextEntry, TextEntry::with_parent').
location(panel_keymap_builder,'crates/improvise-protocol/src/keymap.rs','PanelKeymap').
location(records_layout,'crates/improvise-core/src/view/layout.rs','RecordRow, RecordCol, build_records_mode, records_display').
location(storage_trait,'crates/improvise-io/src/storage.rs','Storage, PhysicalFs, MemoryFs').
location(effect_kind,'crates/improvise-command/src/ui/effect.rs','EffectKind, ModelChange, Effect::kind').
location(prefix_resolution,'crates/improvise-protocol/src/keymap.rs','Keymaps::prefix_keymap; SetTransientPrefix in improvise-command').
location(grid_frame,'crates/improvise-protocol/src/render.rs','AxisFrame, FrameEntry, header_level_widths').
location(browser_harness,'harness/','playwright.config.js; tests/shell.spec.js (chromium); tests/opfs.spec.js (chromium+firefox; webkit has no OPFS)').
location(storage_contract,'crates/improvise-io/src/storage.rs','contract::storage_contract -- public, not cfg(test)').
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\")').
invariant(indexmap_order,'IndexMap insertion order is load-bearing for persistence, and since improvise-shyt so are DataStore::matching_cells/matching_values: the secondary index NARROWS, then matching_positions resolves each candidate to its position in the primary IndexMap and sorts. An index whose values are HashSets is for O(1) lookup, never for iteration.').
invariant(no_model_add_item,'No Model::add_item; use Workbook facade or category_mut().add_item()').
invariant(stale_layout,'app.layout rebuilds only after a whole effect batch; prepend RebuildLayout when needed').
invariant(wizard_back_is_history_stack,'advance pushes the step it leaves onto ImportWizard::history; previous pops it, so back retraces the path actually taken through the conditional transitions rather than re-deriving a reverse table').
invariant(wizard_validate_where_fixable,'preconditions live in validation_error() and are checked on the step where the user can still act -- never deferred to build_model after the final confirm').
invariant(wizard_done_is_confirm,'NameModel is terminal for advance; Enter there calls build(), which parks the workbook in ImportWizard::built on Done. AcceptWizardModel takes it. previous() from Done discards built').
invariant(an_abort_stops_the_chain_not_the_bookkeeping,'Three effects set abort_effects: Save and SaveAs on a failed write or missing path (improvise-8tk, both arms), and AddCategory on refusal (improvise-2zq). An abort at the TAIL of a batch also ate mode restoration and buffer clearing, stranding the user where draw_bottom_bar renders the minibuffer instead of the status line, so the error was never drawn. Tidy-up therefore goes at the HEAD, where an abort cannot reach it -- ordering rather than a runs-anyway flag. An abort is not a rollback.').
invariant(sequence_one_batch,'Binding::Sequence flattens all step effects into one Vec applied by a single apply_effects call, so abort_effects carries across sequence steps').
invariant(minibuffer_spine,'Eight text-entry modes carry a MinibufferConfig. Seven are built by TextEntry::new in improvise-protocol/src/keymap.rs; RecordsEditing is TextEntry::with_parent off Editing, which is why a count taken off the ::new call sites reads seven. SearchMode is hand-written -- no commit key.').
invariant(records_mode_all_none,'ToggleRecordsMode puts every regular category on Axis::None, so EditOrDrill regular_none is always true in records mode and the edit branch depends entirely on is_synthetic').
invariant(buffer_name_single_source,'the buffer a text-entry mode types into is named once, in that mode AppMode constructor MinibufferConfig; TextEntry reads it off the mode so the prompt and the keystrokes cannot address different buffers').
invariant(panel_keymap_spine,'the three panel modes share a spine built by PanelKeymap::new(panel): Esc/Tab to Normal, arrows and j/k to move-panel-cursor for that panel, every panel toggle key, and : for command mode. Content keys come through chained .bind/.bind_args/.delete because they vary in shape not just command name').
invariant(panel_name_matches_parser,'Panel::name() must spell what parse_panel in cmd/core.rs accepts; pinned by panel_toggle_keys_dispatch_to_real_effects, since dispatch swallows an unresolvable command with .ok()?').
invariant(records_rename_spans_row,'editing a category column in wide records mode moves every measure cell in the row; moving only the cell under the cursor would tear the row apart').
invariant(records_dim_is_column_index,'the synthetic records CellKey stores the column INDEX in _Dim, not a label, because a category and a measure item may share a name; resolve via GridLayout::record_col').
invariant(storage_is_two_ops,'persistence only ever reads or writes one whole document, so the Storage trait is read/write bytes and nothing else; a backend that is not a filesystem implements it without directories, metadata or seeking').
invariant(gzip_is_a_name_property,'the .gz suffix selects gzip in save_to/load_from, so it is a property of the document name and not of the storage backend').
invariant(effect_kind_no_default,'Effect::kind has no default impl, so a new effect cannot forget to classify itself; the failure mode would be silent (a mis-declared ViewOnly edit never reaches connected clients)').
invariant(view_lives_in_model_state,'The View is stored inside model_state.workbook, so touching model_state does NOT imply a model change: axis/page/format are layout_geometry, cursor and scroll are per-client view_only, Save/SaveAs are bookkeeping and ViewOnly. MarkDirty no longer exists (improvise-gv9r): App::apply_effects raises model_state.dirty for any EffectKind::Model(_), so a document change that omits it is unrepresentable rather than validated.').
invariant(view_state_is_a_projection,'the server does NOT serialize ViewState to maintain a client mirror -- it applies the same commands through reduce_view (improvise-cqq). Steady-state traffic is Commands only; ViewState is serialized solely for the bootstrap Snapshot').
invariant(bootstrap_excludes_host_state,'because the only serialization point is the on-connect Snapshot, host-only state must not live in ViewState: a fresh client should not inherit a half-open import wizard, an armed prefix key, or another session staged drill edits').
invariant(prefix_is_an_address,'ViewState::transient_prefix stores the (KeyCode,KeyModifiers) that opened a prefix. The table and Keymaps::prefix_keymap are in improvise-protocol/src/keymap.rs; improvise-command wraps them as KeymapSet::prefix_keymap and owns dispatch. Resolution assumes prefixes are FLAT -- guarded by prefix_keymaps_are_flat.').
invariant(compile_check_is_not_runtime_proof,'the wasm CI gate is cargo check, so it catches nothing that merely panics at runtime: Instant and std::fs both COMPILE for wasm32-unknown-unknown').
invariant(effects_never_touch_fs,'every document read or write in the command layer goes through App::storage; production code in improvise-command contains no std::fs').
invariant(io_keeps_path_wrappers,'improvise-io keeps path-taking wrappers (save, load, export_csv, parse_csv_with_warnings) delegating to _to/_from/_bytes variants, so the native CLI is unchanged while the byte-level path is backend-agnostic').
invariant(view_state_send,'ViewState: Send is asserted at compile time in crates/improvise-command/src/ui/app.rs tests. Send is a proxy for portable/1 that only catches the shared-pointer clause; it sees neither the origin-crate restriction nor snapshot_safe/1, so those stay hand-checked against view_state_admits/1.').
invariant(no_borrowed_static_in_view_state,'no type reachable from ViewState may hold a borrowed-\'static field; Deserialize cannot produce one. Guarded by view_state_survives_a_json_round_trip in crates/improvise-command/src/ui/app.rs.').
invariant(semantic_palette_at_the_core_boundary,'a core type that needs a color names a semantic enum, never a ratatui Color; the rendering layer owns the one mapping. Precedents: MinibufColor -> minibuf_fg, AxisColor -> axis_fg. This is what forbidden(improvise_core,ui) requires in practice.').
invariant(filter_outside_rotation,'Axis::next maps Filter to itself and no navigable axis rotates into it, so tile select and cycle-axis cannot hand a user a Filter assignment. Guarded by filter_is_outside_the_rotation in crates/improvise-core/src/view/axis.rs.').
invariant(framing_has_one_home,'ClientFrame/ServerFrame are defined in improvise_protocol::frame and nowhere else. improvise-ws-server and improvise-wasm-client re-export them and define none. the_retired_spellings_do_not_parse guards the narrower case of the retired Bootstrap/Command spellings returning as serde ALIASES, which would be two homes again wearing one type.').
invariant(wire_model_command_needs_a_checked,'no function in improvise-command applies a Command::Model without a Checked, and Checked::against is its only constructor and the only caller of stale_basis. Sessions::apply answers once BEFORE the dispatch. model_effects stays pub and unchecked on purpose -- a host-resolved command carries no claim -- so the rule is about the WIRE FORM, not about mutation.').
invariant(basis_has_no_default_constructor,'a zero-valued Basis is not \"no claim\", it is the claim that the document is FRESH, which in a new App is true -- so a Default would be a forgeable bypass rather than an absence. BasisCache::claim is the only client-side door and it withholds until the server has published one.').
invariant(basis_is_coordination_not_authentication,'Basis derives Deserialize and has public fields, so any claim can arrive on the wire. Rejection::RecordUnstated closes OMISSION, not INVENTION. BasisCache::claim being the only constructor is a statement about clients written against this crate, not about the wire.').
invariant(the_server_decides_which_records_are_checked,'reduce.rs::records_named is an exhaustive read of the ModelCommand, with one or-pattern for the variants that name no record so a new record-writing variant fails the build. Reading the list off the CLIENT claim would be a force by omission: the record tier skipped, no flag to remove, nothing in the message saying so.').
invariant(grid_extent_is_the_whole_grid,'project::grid_of reads GridExtent from GridLayout::row_count/col_count, never from the clipped viewport ranges. The TUI builds its cache at offsets zero so the two coincide there -- the bug is only visible in a browser.').
invariant(client_frame_commands_is_a_batch,'One keypress resolved client-side is SetSelected plus the offsets viewport_effects returns; a host that folds one and not the other projects the wrong window. Since improvise-955n a frame is answered WHOLE by CheckedBatch::against before anything is applied, so Applied::Rejected is unreachable through Host::commands. Clause 4 (all-or-nothing on an apply-time failure) is NOT delivered: App::apply_effects aborts without undoing.').
invariant(opfs_index_holds_documents_not_entries,'OpfsFs::list joins dir like the other two backends, so storage_contract takes dir as a parameter and the three agree at EVERY directory (improvise-vhcz), not only where joining is the identity. The index holds DOCUMENTS: attach indexes nothing (what it creates is zero bytes), write indexes the name and un-queues a pending delete, refresh rebuilds from OPFS skipping zero-byte entries but counting an unmeasurable one as a document, since the only cause is another tab holding the exclusive handle (improvise-vvl3).').
invariant(async_seam_does_not_survive_boxing,'OpfsFs::open/attach/refresh/commit_deletes are on the struct, not on Storage, and App::storage is a Box<dyn Storage>: handing the backend to the App puts the seam permanently out of reach. The host holds the concrete backend and gives the App a shared handle, which is impl<T: Storage + ?Sized> Storage for Rc<T> in improvise-io (improvise-hmws) -- there because the trait is local there, blanket because nothing about it is OPFS-shaped, and Rc not Arc because wasm32 has one thread.').
invariant(shipped_demo_cells_are_settable_or_derived,'web/demo.improv projects CellId::Record for its two raw measure columns and CellId::Derived for Profit, which a formula owns -- never an Aggregate. A Record is precisely the SETTABLE arm; Derived is the one no write is constructible for. Guarded twice and two-sidedly, in tests/web_demo_model.rs against grid_of and in post_message.rs against the real seed-VFS-open-connect-Snapshot chain. Read that test boolean carefully: it is expected_derived, NOT editability, so the arms are (CellId::Record(_), expected_derived false) and (CellId::Derived{target}, expected_derived true, target == \"Profit\") -- describing the first as \"editable false\" states the inverse permission, which is what this clause said until it was corrected. Before improvise-ir7l both guards said \"every cell is a Record\" and PASSED while that was false for a third of the landing-page grid.').
invariant(matching_cells_yields_insertion_order,'DataStore::matching_cells/matching_values return the primary IndexMap order. Measured at 18,000 cells: +20% worst case on matching_values, matching_cells 7% FASTER because sorted positions resolve CellKeys with better locality.').
invariant(a_stored_fit_names_only_what_the_document_holds,'fitted_formula refuses a tree splitting on a feature Model::find_item_category cannot resolve. It asks the DOCUMENT, not the name shape: a measure literally named Region=North is legal and indistinguishable from a one-hot as a string.').
invariant(the_frame_is_answered_once_not_the_command,'Since improvise-955n, CheckedBatch::against answers a whole ClientFrame::Commands before anything is applied. The two DOCUMENT tiers (document_generation, layout_version) hoist to the frame; the RECORD tier does not, because which records are at stake is a property of the command (records_named). Hoisting all three was a lost update: [SetCell(North), SetCell(South)] overwrote a South another client had moved, silently, invisible to the other two tiers because a SetCell is CellData. Every mutation answers for its own records against the FRAMEs basis -- never its own field, or a later command could claim a fresher picture than the frame was composed under.').
invariant(the_create_exemption_is_a_property_not_a_list,'A record that NEITHER resolves in the document NOR is claimed by the basis is one the frame is about to create, and only that combination is exempt from the record tier -- because BasisCache::claim filters to records the cache was told about, so [AddItem(East), SetCell(East)] could not claim one however it is ordered. A record that does not resolve but IS claimed is still RecordGone.').
invariant(a_cats_change_reidentifies_every_position,'A cells identity is AxisFrame::cats zipped with its label tuple, so GridCache::invalidate_relabelled blanks the WHOLE axis when old.cats != new.cats -- not the positions whose tuples differ -- and blanks the totals on a recategorization of EITHER axis, since a column total sums over the rows. It is applys job and not the producers, for the reason the whole fold works that way: an update must be correct with no follow-up behind it (gotcha 33b).').
invariant(the_header_width_rule_has_one_home,'improvise_protocol::header_level_widths: per level, the widest label plus a gap, clamped (improvise-1125). It lived twice -- inline in src/ui/grid.rs::render_grid and again in ui::viewport::compute_row_header_width -- and a browser sizing its own row header would have been a third. It takes a borrowed iterator rather than an AxisFrame because its other caller holds a GridLayout on the keypress path and building a frame there would clone every row tuple on every key.').
invariant(saving_is_a_frame_not_a_command,'ClientFrame::Save (improvise-368h) carries no path -- a client cannot see the hosts storage -- and is a FRAME because as a Command it put a runtime-fallible operation inside a batch ClientFrame::Commands promises is whole-or-nothing, so [Save, SetCell] reported a failed write and applied the edit anyway. It also fails the test every Command passes: it mutates no shared state and no clients slice, so reduce_view has nothing to fold and Checked has no basis to answer for. Refused as NoDocumentPath or SaveFailed to the asker alone; success is silent.').
invariant(the_persist_trigger_is_an_observation,'improvise-worker-server persists on model_state.dirty, not on a table of Command variants (touches_document is deleted) -- the same choice as watching cursor_epoch rather than matching CycleAxis, and document_generation rather than matching LoadModel. It is strictly tighter: a refused batch raises nothing, so the \"was anything rejected\" guard went with it.').
invariant(the_harness_runs_the_worker_chain_under_chromium_only,'harness/ (improvise-vvl3/xf1) executes improvise_io::storage::contract::storage_contract against a real OpfsFs in a real dedicated Web Worker under Chromium and Firefox, gated by the browser job in CI; two bugs fell out of the first run. Playwrights WebKit provides no OPFS at all (getDirectory rejects with UnknownError), so xf1s three-engine criterion is met for two of three and the project is kept so it starts the day that build gains OPFS. harness/tests/shell.spec.js runs web/app.js under CHROMIUM ONLY -- engine-independent logic, and routeWebSocket is slower across three. It also runs BINDINGS.RS: its no-?ws test loads the app, which spawns web/worker.js, which imports the glue, which calls bindings.rs start() and OpfsFs::open(), and the test then asserts a grid draws. This invariant was named opfs_is_run_but_bindings_is_not and asserted the opposite in FIVE places for months (here, the crate/3 fact above, two context/repo-map.md lines and improvise-bck) -- every copy written before the harness landed and none re-read after. A never-run claim in prose is unfalsifiable by anything in this repo: the CI job checks this KB for CONSISTENCY, not truth, and the error UNDERSTATED coverage so nothing failed and nobody looked. What is genuinely unrun, BY ENGINE: Safari/WebKit (no OPFS in Playwrights build) and any non-Chromium engine for the shell spec. That is deliberately scoped to engines rather than written as a closed list of everything unreached, because a closed list is how the previous version of this invariant went wrong in the other direction: two wasm_bindgen exports on WorkerServer, documents() and refresh(), have NO caller in web/*.js or harness/ and so are run by nothing (the .refresh( hits in harness/opfs-contract are OpfsFs::refresh, a different method). The lesson the rename is about applies to its own replacement: a coverage claim in prose is unfalsifiable by anything in this repo, so state what IS covered and scope what is not, rather than enumerating an absence.').
invariant(a_different_permission_is_a_different_cellid,'ADR 002 clause 1 applied rather than departed from: CellId has four variants, and each names a permission. Record is SetCell, Aggregate is Drill, Derived is a cell a formula owns (no surrogate, so no write is constructible -- improvise-ir7l), Create is a records-mode creating column whose operand is a NAME (improvise-poia). None means only \"no projected cell\". cell_id asks derived BEFORE the record branch, since a formula target is a real stored coordinate, and AFTER the aggregate branch, since a formula cell that aggregates is still drillable (gotcha 10) -- reversing the two turns a drillable cell into a dead one.').
invariant(the_page_filter_split_lives_in_the_type,'GridLayout::page_coords is Vec<PageAxis>, and PageAxis::items is Some exactly for Axis::Page, so a Filter axis carries no list and no caller can compose a cycle for one (gotcha 27). page_cat_data and the browsers page_cycle both READ it; neither spells the rule again. The list is effective_item_names -- the same call that chooses the default selection, so the selection and the list it indexes into agree by construction -- and is NOT filtered to visible items, because the TUI cycles hidden ones.').
invariant(the_title_bar_cannot_be_gated_by_an_effectkind,'dirty is raised by any EffectKind::Model(_) and cleared by effect::Save, which is ViewOnly; SaveAs moves file_path and is also ViewOnly. So Dirty::of reports NOTHING for exactly the effects that change this widget most visibly, and gating it would leave every client showing [+] for a saved document forever. ClientSession::document_update OBSERVES instead -- build the desired cache, diff against the mirror -- the same answer record_versions got. stored_as is a NAME, never a path: a socket server projecting file_path would hand a remote client the hosts directory layout, and None carries the fact ClientFrame::Save fails on.').
invariant(a_front_end_owns_an_event_map_and_painting,'improvise-0yq8 moved ClientState and resolve/step into improvise-protocol: both depend only on ViewState, the widget caches and the announced window, all already protocol types, so the move cost no dependency. What a new front end still writes is its own event -> KeyEvent mapping and its painting. improvise-wasm-client/src/keys.rs is BrowserKey and nothing else, 1273 lines -> 188. tests/lockstep.rs STAYS: it holds resolve against improvise_command::ui::reduce::reduce_key, the host Cmd/Effect path -- two derivations in two crates, one unreachable from a browser -- so moving a mirror between crates does not stop it being one.').
invariant(a_ui_string_is_keyed_by_a_command_never_by_a_key,'improvise-protocol::help owns mode_label, hint_line, which_key and pages; AppMode::label/hint_text are gone (improvise-k099) so the enum that crosses the wire carries no English. Hint{keys,command,caption}: keys looked up in Keymaps::default_keymaps(), command the semantic value a localized client reads, caption the default English -- semantic_palette_at_the_core_boundary applied to text. Three rendering rules, each with a silently-wrong alternative: hint_line drops chorded keys, runs single-character keys together and slashes the rest, and drops a hint that compacts to nothing (a chord-only command rendered a bare \":caption\"). A help row whose command resolves to no keys is DROPPED -- keys() returns Option so that is unrepresentable rather than validated. Not derivable, and said so rather than faked: the import wizard (one Any binding into HandleWizardKey, which decides per step inside the command), gg/yy/ZZ and the ex-commands (HelpLine::Literal), and the captions, which no test can judge.').
invariant(fit_is_denormalized_before_it_is_written,'gp_tree fits a standardized target, so a champion written down untouched predicts standard deviations. fit_csv prints the affine map beside the tree; a document cannot, so improvise_gp::fit_measure calls gp_tree::fit::denormalize first. Leaves only -- standardize rescales the target, so a split threshold is already in its feature units').
invariant(a_fit_is_a_new_measure,'a fitted rule is stored as <target> (fit), never as the target: _Measure items are explicit items union formula targets (gotcha 7), so a formula on the target would make the two one item and the cells the fit was learned from would stop being visible').
invariant(measure_axis_never_filters_a_fit,'_Measure is in View::filtering_cats() (Page union Filter) and Workbook::new puts it on Page, but it is the coordinate a feature matrix spreads its COLUMNS across -- so improvise_gp::dataset::filter_coords skips it. Pinning it is not \"which records am I looking at\", it is \"throw away every column\"').
invariant(a_fit_is_bounded_in_width_not_only_in_search,'FitConfig bounds the GA; nothing bounded the encoded feature count, which preprocess can drop to zero (a panic: random_tree draws below(0)=0 and sample_thresh indexes ranges[0]) or multiply without limit (one column per distinct categorical value, and permutation importance is repeats*rows*features). improvise_gp::fit_measure refuses both, and MAX_FEATURES names Label as the fix').
invariant(dataset_columns_are_not_header_aligned,'gp_tree::data::preprocess continues past an entirely-missing column WITHOUT pushing a ColumnSpec, so Dataset::columns is not index-aligned with the header it was built from. Ask a column role by NAME, or off the header feature_table built -- never by position into columns').
invariant(no_input_line_is_dropped,'The Markdown renderer covers exactly the subset the corpus uses; anything it does not recognise falls through to paragraph text. The failure mode is an ugly paragraph, never a missing one.').
invariant(a_table_cell_boundary_is_an_unescaped_pipe_outside_a_code_span,'splitRow walks the row rather than split(\"|\"), because this corpus documents a file format whose own quoting syntax is |Time Period| and a cell containing one would be shredded into extra columns -- a row that still parses, so no column count could detect it. Latent when fixed, which is the point: the trigger is one ordinary sentence away in a document whose subject is pipe quoting.').
invariant(code_spans_stand_aside_for_emphasis,'Inline rendering replaces code spans with placeholders before matching emphasis, because this corpus routinely opens ** on one side of a code span and closes it on the other; matching per segment silently drops that bold with no visible symptom. Strong is matched before emphasis with a body that may contain a single * pair, because [^*]+ cannot match **a *b* c** and the leftover * then pairs across the bold -- which turned one sentence of gotcha 33 into three italic paragraphs.').
invariant(key_matching_is_variant_equality_never_unification,'scripts/kb-merge.pl compares clause keys with same_key/2 (copy_term + numbervars), never by unifying them. Unification matched too much -- a non-ground key probe(X) matched probe(a) and deleted it -- and MUTATED the incoming clause, binding X to a, so the clause written was not the clause supplied; the round-trip verify cannot see that, since it compares the file against an intended list already corrupted by the same unification (improvise-dj1l). A RULE keys on the whole clause, because one head shape with different bodies is ordinary Prolog. check_total/2 asserts the arithmetic the tool already PRINTED beside the delta size.').
invariant(duplicate_category_sections_merge_in_pass_one,'Two ## Category: sections naming one category are ONE category, concatenated by ParsedFile::push_category in pass 1 (improvise-349m). Merging in pass 2 was not idempotent: build_category creates its own section item-groups before its own > lines, while format_md writes every item before every > line, so the reparse built the other order. push_category destructures PCategory so a fourth field cannot be silently dropped on the duplicate path. No grammar change was needed -- this is the tree-walker, not the format, and duplicate headers stay legal.').
invariant(where_and_is_share_a_word_boundary,'where_clause, inline_where and is_expr are all compound-atomic with sp+ after the keyword (improvise-r08u). Non-atomic, pest auto-inserts WHITESPACE and SUM(Rev WHEREAS x = y) reads as a WHERE on a category AS. Note compound-atomicity ALONE refuses that input, since AS x cannot be one identifier, so the residue sp+ actually closes is WHEREAS=y -- a negative test needs both shapes.').
invariant(evaluate_aggregated_takes_a_named_state,'Model::evaluate_aggregated takes Aggregation::{Exact, OverHidden}, not none_cats: &[String] (improvise-5jm5). The list was consulted once, for emptiness, in every revision since Axis::None was introduced -- the aggregation runs over key.0 via matching_values. Aggregation::of reduces at the boundary and the regression guard is the SIGNATURE. recompute_formulas and aggregate_raw keep &[String] because they walk the elements.').
invariant(normal_mode_is_resolved_not_carried,'AppMode::Normal is resolved through App::normal_mode() when the mode is SET -- ChangeMode and ReturnToNormalUnlessMoved are the only two places a batch sets it, and CloseWizard reads the same function -- so no keymap binding and no commit command carries an exit mode, and none can forget one (improvise-sihr). It reads the batch OPENING layout (gotcha 23), which is the property rather than a defect: that is the grid the user was looking at, and toggle-records-mode works in both directions because of the staleness rather than despite it. Carrying the entry mode on MinibufferConfig was the ticket proposal and was declined -- carried generically it also returns CategoryAdd to CategoryPanel. Known gap, filed as improvise-e5pf: Command::ChangeMode(Normal) is on the wire and reduce_view folds it without the resolution.').
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).').
doctrine(coverage,'aim ~80% line/branch on logic; skip ratatui render paths').
doctrine(test_contract,'test the observable contract, not the internal storage').
doctrine(proptest_cases,'keep proptest at default 256 cases; constrain strategies instead of brute force').
doctrine(binding_dump_diff,'when refactoring keymap construction, dump every (mode,key,modifier) lookup for all 17 ModeKeys before and after and diff: most bindings have no direct test, so a green suite is not evidence').
doctrine(derive_then_verify,'when classifying many items from source, extract facts mechanically and reason in Prolog, but VERIFY the extraction: multi-line method chains and free-function helpers defeated naive regexes and mis-classified SetCell, SortData and ApplyAndClearDrill as view-only. A line-oriented awk over effect.rs likewise reports 30 Model effects where brace-matching the impl blocks gives the true 29 -- HelpPageSet is ViewOnly and the running variable bleeds across the #[cfg(test)] boundary.').
doctrine(mentions_are_not_edges,'never bulk-add dependency edges from prose mentions; direction must be read per pair, and blind addition can invert real edges or create cycles').
doctrine(kb_is_code,'the knowledge base is executable, so audit it like code: check rules actually reject what they should, and check location/3 symbols still exist in the tree').
doctrine(check_the_checker,'prove a consistency check fires by injecting the fault it targets, then revert; otherwise it is indistinguishable from a check that always succeeds').
doctrine(prove_by_compile_failure,'for a structural refactor with no behavioural delta, a static assertion that fails to compile before the change is the demonstration -- it names the offending type itself, and survives as a guard that the field cannot come back').
doctrine(populate_before_roundtrip,'a serialization roundtrip test over a default-constructed value proves almost nothing; populate one non-default value per field family, especially the variants that carry payloads. Note an Option field is defaulted by serde anyway, so deleting its #[serde(default)] fails no test -- aim the injection at the field loading as the DEFAULT rather than as unknown.').
doctrine(check_the_ticket_premise,'a ticket that states a fact about the system as the basis for its options is stating a hypothesis; test it before choosing among the options, because a false premise usually invalidates the option set rather than one option').
doctrine(characterization_survives_the_move,'for a relocation refactor, write the behaviour test against the OLD location first and carry it across unchanged in meaning; if it needs new assertions to pass, the relocation changed behaviour').
doctrine(two_questions_one_enum,'when a new enum variant shares one property with an existing variant but not another, name both properties as predicates (filters_data, is_user_assignable) and route every call site through the one it means; testing for the variant directly is what goes stale when a third variant arrives').
doctrine(contract_test_for_the_unreachable_backend,'when one implementation of a trait cannot be exercised by the test harness, write the contract as a function over &dyn Trait and run it against the reachable implementations; it becomes the executable specification the unreachable one is held to').
doctrine(derive_the_enum_from_the_effects,'when a wire vocabulary must mirror an existing mutation set, enumerate the mutations and let that be the enum; inventing variants ahead of the mutations produces a protocol nobody can implement against').
doctrine(force_the_failure,'an unchecked status whose failure yields a usable-looking value is not a style nit: force the failure and look at what the next line does with it. Four on one branch -- bd init exits non-zero but still leaves an empty database behind, an unchecked mv lands the new tracker INSIDE the old directory and reports success, jq status swallowed by a process substitution reads as \"no inputs found\" and exits 0, and a failed mktemp turns \"$TMP/$NAME\" into an absolute path at the filesystem root. Every one looked like success at runtime, so reading the failure paths found none of them; stubbing mv and mktemp to fail is what exposed them. Note chmod a-w proves nothing when the tests run as root -- it returned 0 and looked like a pass. The same shape appears in tooling: requesting a Copilot PR review over REST returns HTTP 200 and silently ignores the reviewer.').
doctrine(an_injection_that_did_not_compile_reads_as_a_clean_run,'a fault injection is evidence only if the build RAN. A grep for compiler errors that matches ^error[ but not a bare error: line turns a failed compile into a green read -- the identical defect class injection exists to catch, one level up in the instrument. Caught mid-review on PR #48 by the reviewer in its own tooling, one step from a false unguarded finding, and then re-checked against the earlier runs rather than assumed away. The fix is not a wider grep, because a pattern can always be one case short: require a POSITIVE signal that the injected build ran -- the test count moved, or the named test appears in the output. A count cannot be silently empty.').
doctrine(fold_tests_must_test_order_not_the_axiom,'a test asserting fold(prefix) then fold(rest) == fold(whole) is VACUOUS: that identity is the definition of a left fold and holds for any apply() whatsoever. Proven 2026-09-19 by rewriting GridCache::apply to write every cell update to (0,0) -- both headline fold tests stayed green while the two real tests failed. Test the properties the design actually rests on: ORDER SENSITIVITY (which updates are dropped when they arrive early, i.e. that the fold is deliberately not commutative) and COMPACTION EQUIVALENCE (fold(compact(history)) == fold(history)), which is what \"seeded, then follows the event stream\" means in production. A seed taken as the literal head of the history presupposes the conclusion.').
doctrine(a_macro_must_not_supply_a_forced_classification,'invariant(effect_kind_no_default) makes the compiler demand an EffectKind per effect precisely because a wrong one is silent. A macro that hard-codes the kind reintroduces the default under another name, for a category defined by SYNTAX (one field, one place) rather than SEMANTICS (per-client). Proven 2026-09-19: a simple_effect! instantiation writing the active view number format -- a textbook LayoutGeometry change -- compiled, reported ViewOnly, and passed all 294 command tests. If a macro covers a classified thing, make the classification a mandatory argument even when the answer is always the same.').
doctrine(one_arm_of_a_symmetric_rule_is_evidence_about_that_arm_only,'when a rule must hold on several arms -- both directions of a fit, every variant that touches one collection, each side of a symmetry -- a test driving ONE arm says nothing about the others, and will sit there green while the rule is violated next door. Worked example 2026-09-19: improvise-rkeq fixed col_widths never being re-fitted when ColLabels changed, and fixed only the label arms; the ColWidths arm, named after the very collection it fits, kept assigning verbatim and put the length mismatch straight back. col_widths_always_span_the_column_labels passed the entire time because it only ever drove the label arm. Copilot caught it on the PR. Enumerate the arms and test each, or assert the rule over a generated set of arms rather than one chosen by hand.').
doctrine(lockstep_binds_every_session_not_the_asker,'when two copies of a SHARED value must move together and one copy is per-client, moving it for the client that ASKED is not lockstep -- it is a desync with a polite name. Proven 2026-09-19 (improvise-kvyt, found by Copilot) on active_view, and the reasoning generalises past the example that taught it: ask of any shared value, who else derives something from it? improvise-jytm then REMOVED active_view from the doctrine scope by making it per-client (the model is shared, the UI is not), so what remains under it is the document half -- cursor_epoch, document_generation -- and the doctrine is now a test for whether a value is shared AT ALL before it is a test for who hears about it.').
doctrine(an_invariant_is_made_unrepresentable_not_validated,'when a reviewer asks a function to validate a precondition -- matching lengths of parallel slices, a count supplied alongside the collection it counts -- prefer changing the type so the bad state cannot be built. Proven twice on one branch 2026-09-19: prune_axis_items stopped taking &[bool] plus a debug_assert and took a predicate over the data index instead, so no parallel length exists to get wrong; and the same reviewer request against gp-tree five public scoring functions was declined in favour of giving them one slice of rows rather than parallel x/y, which removes the zip truncation AND the empty-input NaN as one shape rather than four edge cases. Validation also pushes Result into five signatures, which AGENTS.md keeps at the boundaries.').
doctrine(same_kind_is_not_the_same_effect,'a classification shared by an operation and its INVERSE cannot check a mapping between them. Proven 2026-09-19 (improvise-kmgs review): model_effects sends 28 ModelCommand variants to one effect each, and HideItem/ShowItem, AddItem/RemoveItem, CreateView/DeleteView and SetCell/ClearCell all declare the same EffectKind -- so swapping three of them simultaneously left the workspace at 918 passing, 0 failing, with three wire messages doing the exact inverse of what they said. A test asserting only that the target declares the right KIND is therefore no guard at all. Where a mapping is name-for-name by construction, ASSERT THE NAME: it is total, costs nothing at runtime, and a variant whose effect must be named something else is a variant that stopped being derived and should fail loudly.').
doctrine(a_rename_can_hide_a_stale_key,'a fixture that RENAMES a key across the change under test can make a stale entry unreachable rather than wrong, so the test passes for the wrong reason. Proven 2026-09-19: the first document-swap test used a fixture whose incoming active view was renamed Default->Sheet, which made apply_model broadcast a SwitchView; the client folded it and then read its cursor under a key nothing had written -- right by accident, bug invisible. The replacement keeps the view NAME and changes only the shape, so there is nothing to hide behind. General form: when testing that stale state is corrected, hold the identifiers constant and vary only what makes the state stale. Same family as the one-category fixture that could not tell TransposeAxes from CycleAxis.').
doctrine(an_injected_fault_must_be_the_fault_you_named,'a check-the-checker is evidence only if the injected fault is the ONE it claims. Proven 2026-09-19 (improvise-cr3 review): a_frame_named_the_other_envelopes_way_does_not_parse rewrote the Commands key to Command and deleted the opening bracket with it, leaving a trailing bracket -- so the string was not JSON and is_err held for a SYNTAX error. Verified by adding the variant it claimed to exclude: the old assertion still passed. Two corrections generalise: assert the mutated input is still WELL-FORMED before feeding it in, and assert the ERROR KIND (serde unknown variant), not merely that an error occurred. A check that fails for a reason other than the one under test is check_the_checker own failure mode wearing the name of its cure.').
doctrine(a_hand_enumerated_mirror_needs_an_exhaustive_match,'when one crate MIRRORS another enum because it cannot depend on it, a round-trip test over a hand-written fixture list covers only the variants someone remembered, and is blind to a variant added on the mirrored side. Pair it with an exhaustive match over the OTHER crate type in a test module, so the addition becomes a compile error in the mirroring crate rather than a runtime surprise at the far end.').
doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished,'two definitions kept in step by a round-trip test are ONE DEFINITION WAITING TO BE MADE. When they merge the test must DELETE: a rename becomes a compile error at every call site and an un-mirrored variant becomes unconstructible, which is stronger than any round trip. Proven 2026-09-20 (improvise-xks6). A surviving compatibility test after a merge is evidence the merge did not happen.').
doctrine(a_missing_variant_is_a_date_before_it_is_a_decision,'when two vocabularies for the same job differ, ask of each difference whether the ARGUMENT for it names the thing they differ in. In improvise-xks6 none of Viewport, Key or batching mentioned a transport -- each was justified by the session model or the fold -- so each difference was an unfinished feature or a stale file. THE TELL FOR STALENESS IS INCOHERENCE RATHER THAN DIFFERENCE: the worker envelope carried a Basis it had no Rejection to answer.').
doctrine(a_struct_variant_closes_omission_not_ignoring,'making a field mandatory in the MESSAGE stops a sender forgetting it. It does nothing to stop a RECEIVER discarding it: the two-dot rest pattern is exactly that, with no compile error. If the receiver must not ignore it, the receiver PARAMETER is the place to say so -- improvise-ondj gave apply_command a Checked newtype rather than a guard.').
doctrine(a_documented_door_with_no_caller_is_the_dangerous_kind,'reduce_full was named by ui/reduce.rs module doc as the entry point every host should use, and used by NONE of them. Nothing exercised its Command::Model arm, so nothing could notice it skipped the basis check. Audit the functions the DOCS ADVERTISE, not only the ones the callers reach.').
doctrine(the_audience_follows_the_optimistic_fold,'a relay is addressed by whether the asker folded the command optimistically and by whose UI the change reaches. A Command::Model is never folded optimistically, so every relay out of apply_model is ToAll. A view switch IS folded optimistically and reaches nobody else UI, so an accepted one relays NOTHING and a refused one is To(asker) alone -- improvise-6c6q, where ToOthers told every client but the one that was wrong. Two traps, both proven by injection on PR #48. A test written through a what-do-the-others-hear helper looks away from the asker by construction, which is how the sibling bug in apply_model shipped green. And a test whose bystander is already on the value being relayed cannot see a wrongly WIDENED audience either, because the wrong relay folds to a no-op -- so assert the Broadcast ITSELF, since the audience is the message, and seat the bystander somewhere it can be yanked off.').
doctrine(half_a_lockstep_fix_is_worse_than_none,'resetting the server mirrors without telling the clients makes the DIFF believe they are already current, so it sends nothing: stale FOREVER rather than until the next scroll. When a fix has a server half and a client half, shipping one is not partial progress, it is a worse bug than the original.').
doctrine(force_is_a_recomposition_not_a_bypass,'force never applies a batch whose basis is stale; it re-composes the same INTENT against the current state and submits a fresh batch with a current basis. A mechanism whose only purpose is to violate the invariant is not a feature, it is the invariant deleted with extra steps.').
doctrine(a_simulated_fix_must_be_the_fix_you_named,'The sibling of an_injected_fault_must_be_the_fault_you_named, one level up, and it fails in the opposite direction: an injected FAULT that is not the one named proves a check that does not fire; a simulated FIX that is not the one named proves a BUG THAT DOES NOT EXIST. Worked example: the harness agent measured the 8s watchdog overwrite honestly, on a tree predating PR #21, then reported it \"survives improvise-taxx, confirmed by simulating the opened flag\". PR #21 is TWO changes -- the flag AND routing both close arms through setShellFailure, which clears the timer. Simulating half of it reproduced the bug and read exactly like a confirmation. improvise-xps8 was filed on that report, propagated into repo-map gotcha 48 and a spec comment, and was closed INVALID. The repair is not a deletion: the property is now executed by a test that outlasts HANDSHAKE_TIMEOUT_MS, proved discriminating by removing setShellFailures clearTimeout and watching \"sent no document\" appear.').
doctrine(an_order_test_needs_two_histories,'a single-process test cannot tell \"sorted correctly\" from \"hashed that way today, on this machine\". Compare against the insertion-ordered structure itself, or build the same logical data by two DIFFERENT insertion histories and require each to list its own. Corollary of check_the_checker.').
doctrine(a_helper_that_builds_the_world_the_code_wants_tests_nothing,'improvise_eval FABRICATED a _Measure item per feature name, so it proved the emitter and gp_tree::eval agree against a model that could not exist. The fix is not to delete such a test -- it checks real things -- but to state its scope and assert the PRECONDITION separately against a real fixture. Pair a refusal test with an accepting one, or the refusal can pass by refusing everything.').
doctrine(a_session_local_widget_gets_a_cache_and_no_projection,'a Projection is addressed to a widget on EVERY client, which is right exactly when the change is addressed to NO ONE session. A half-open dialogue belongs to precisely the session that opened it, so WizardCache is built and drawn locally with no Projection. Same argument that removed AcceptWizardModel from ModelCommand, applied to rendering rather than to mutation.').
doctrine(an_upstream_key_is_a_black_hole,'Host::handle answers ClientFrame::Key with an empty vec, so a key a client does not resolve does nothing at all. Every KeyOutcome::Upstream is a DEAD binding, not a slow one -- weigh remaining client-side resolution gaps accordingly.').
doctrine(mirror_the_cmd_line_for_line,'a client-side resolver reproduces its Cmd exactly, INCLUDING where sibling commands disagree (Move emits on a clamp, MovePanelCursor does not). Tidying them into one rule makes the client a second derivation, which is what lockstep testing exists to rule out.').
doctrine(a_window_is_not_derivable_from_a_projection,'row_labels.len() is the window CLIPPED to the grid, so it equals the window everywhere except the bottom edge -- which is exactly where scroll arithmetic matters. A client learns its window only from what it announced, which is why recording belongs on the call that announces it rather than in a second call a page could forget.').
doctrine(a_gap_is_demonstrated_not_asserted,'ClientFrame::Key is answered with nothing because nothing in the projection stream carries the HOST ViewState, so a half-wired arm diverges rather than under-delivers. That is pinned by a_key_resolved_on_the_host_moves_state_no_client_can_hear, which fires reduce_key against a real App and asserts the session mirror did not follow -- a failing property rather than a comment.').
doctrine(a_page_that_cannot_work_says_so_statically,'a deployed page that cannot reach its server states that in STATIC HTML, not in code that runs only if the thing it explains is working.').
doctrine(a_watchdog_names_the_condition_not_the_peer,'a worker that catches its own start() rejection fires no error on the main thread. Guard \"the peer never answered the handshake\", which means the same thing over a socket and over a port, rather than each peer separate failure modes -- a check aimed per-peer would have missed exactly this one.').
doctrine(a_demo_is_checked_against_the_type_not_the_axes,'ask grid_of what a cell IS -- a CellId variant, per ADR 002 -- never re-derive the rule from the view config: a second derivation can agree with itself while disagreeing with the client. The assertion is written BEFORE the model, and proved discriminating against the model that fails it.').
doctrine(being_the_second_consumer_audits_the_abstraction,'improvise-9x6 framed the worker as a wasm-flavoured analogue of the socket server, and the transport work was nearly free. The four real obstacles were Host in the wrong crate, Save unreachable from the wire, dirty never set by a wire edit, and an async seam unreachable through a Box -- and THREE OF THE FOUR were gaps in the SHARED layer that the socket server also had and had never noticed. A second real consumer is what audits an abstraction; that is a stronger argument for having something to test than any specific check.').
doctrine(a_load_bearing_order_needs_an_order_test,'an ordering documented as load-bearing and held up only by the order of two statements is a check nobody has seen fail. Projection::Basis is emitted after the grid updates whose counters it states; that was asserted in three doc comments and tested nowhere until a test was added and proved by injecting the swap.').
doctrine(a_fact_stated_twice_is_changed_by_grep,'When a fact is stated in more than one place, changing it is a grep and not an edit -- and the obligation attaches to the FACT, not to the edit: writing a NEW statement of an existing fact obliges you to find the places that already state it. A new paragraph restating an existing fact is a second derivation, in prose, where the compiler cannot see it. Evidence at 22d43b2: gotcha 44 was rewritten in design-principles.md and in src/ui/grid.rs and left stale in context/repo-map.md; \"there is no browser here\" survives in seven places after harness/ made it false, including in the spec file that falsifies it. And this is how it RECURS: the same commit that corrects one copy is the one most likely to miss the others, because its author believes the fact is now fixed. Demonstrated twice in one day -- improvise-s17o left three statements standing including one 200 lines below the paragraph it corrected, and PR #45 created a false SIBLING note while fixing the fact it described.').
doctrine(a_reviewed_paragraph_is_not_evidence,'A recipe in a doc is code and must be run in the head. The AGENTS.md dead-ID audit scanned only context/repo-map.md and context/adr/*.md, so it found a dead id in the map and missed both copies in harness/tests/shell.spec.js; its [a-z0-9]{3,4} class could not see a commit SHA wearing the improvise- prefix; and its exclusion list named only core and wasm. A verified SPELLING is not a verified BEHAVIOUR: playwright.config.js lists three projects, and exactly one spec runs on more than one engine while a third engine has no OPFS at all.').
doctrine(a_fast_per_position_accessor_invites_the_loop_it_replaces,'AxisFrame::shows_level materializes every item on every call and is deliberately NOT made cheaper, because a cheap per-position form is an invitation to the quadratic draw loop header_labels exists to replace. The per-position accessors survive for one-off lookups and say so in their own doc.').
doctrine(a_derivation_correct_per_element_can_be_wrong_for_a_set,'improvise-kmgs derived ModelCommand from the model effects ONE AT A TIME, which was right per effect and put SetCell on the wire without its MarkDirty companion -- so \"save if dirty\" was a trigger that never fired and a wire edit was lost silently (improvise-gv9r). The repair was not a second table but raising the fact where it cannot be omitted: App::apply_effects reads dirtiness off the same EffectKind the Dirty and layout_version are read off.').
doctrine(reachability_is_a_reason_not_to_rush_not_a_reason_not_to_notice,'CheckedBatch documents clause 4s hole -- a chain whose later command fails at apply time leaves its predecessors applied, and silently -- rather than closing it, because no client in the workspace can compose it. The lost update the same commit FIXED had exactly that reachability argument and none of the documentation, which is how it shipped.').
doctrine(a_ticket_id_and_a_commit_sha_must_not_share_a_prefix,'crates/improvise-core/src/workbook.rs cited a git commit sha wearing the improvise- ticket prefix. A reader runs bd show, gets nothing, and concludes a follow-up was lost. A dead ID is worse than no ID; an ID-shaped non-ID is worse again, because the audit loop reports it identically.').
doctrine(redundant_earns_a_deletion_unused_earns_a_note,'improvise-n89z had three wire variants with no producer and gave them two answers. GridUpdate::Invalidated was DELETED: its meaning had migrated into running code (invalidate_relabelled blanks exactly what a reframing invalidates), and there is no state in which a server knows contents are stale and not WHICH -- grid_updates diffs and seed compacts -- so it was REDUNDANT. CategoryTreeUpdate::Items/Axes STAY with the argument written down: they are derived from the effects by the recipe that built ModelCommand, so they are merely UNUSED, and emitting them needs the tree observed and diffed for a widget costing a few hundred bytes. Three documents describing a variant as load-bearing while nothing produces it is worse than silence.').
doctrine(a_client_side_test_cannot_say_what_a_producer_emits,'A test that hand-builds the projection it then folds is testing the fold, and says nothing about the producer -- it fabricates the very data the code under test is supposed to make. Found 2026-09-20 on improvise-p1ym self-review: a_filter_axis_is_projected_but_not_cycled builds its own PageAxis with items: None, so it would have passed with grid_of emitting items for a Filter axis, which is the gotcha-27 trap the ticket exists to close. Every client-side fold test over a new projection needs a producer-side sibling asserting what is actually emitted.').
doctrine(a_lockstep_test_must_drive_two_documents,'Rebuilding the clients cache from the host after each step and then comparing the client against THAT HOST is two readings of one value, and it is vacuous. Proven 2026-09-20: a_locally_resolved_page_turn_lands_where_the_host_would_put_it stayed green with the odometers significance reversed. The fix drives two independent documents -- a Host fed the commands the client composed, an App fed the same keypress through reduce_key -- and compares View::page_selection, the DOCUMENT, never a projection. Pair it with an assert_ne! against a fresh fixture, or a resolver that emits nothing passes by moving neither.').
doctrine(a_derived_string_deletes_the_test_that_policed_it,'every_hint_key_resolves_in_its_mode and its 60-line hint tokenizer existed to hold a hand-written AppMode::hint_text honest against the keymaps. Deriving the keys from default_keymaps() makes that direction true by construction, so the test and Keymap::binding_hints were DELETED -- a_consolidation_that_keeps_its_compatibility_test_is_unfinished, with the deletion as the measure. What replaced it checks the direction that was never checkable before: a mode advertising a command nothing binds. Note what neither version reaches, which is the honest residue: the CAPTION. \"i:edit\" where i enters records mode passes both.').
doctrine(a_fixture_that_sits_on_one_arm_tests_one_arm,'The one-arm rule biting in a fixture rather than in a match. Proven 2026-09-20 on improvise-e24v: four faults were injected into the new client resolvers and three failed a named test immediately, but corrupting the status sentence in DeleteCategoryAtCursor CATEGORY arm did NOT, because the lockstep fixture had its panel cursor on an item row throughout and never entered that arm. The fixture now visits a header row as well. Generalise: when a Cmd branches on WHERE A CURSOR IS, the fixture must visit each place, and the injection pass is what reveals that it does not -- reading the test does not.').
doctrine(no_evidence_is_not_a_verdict,'a metric computed over an empty sample is not the same statement as a bad metric. cv_regress pools nothing below two folds and regression_quality of an empty pool is R2 exactly 0.0, which reads as \"this rule is worth nothing\" -- the loudest thing a fit can say. Quality is an enum with Measured and Unmeasured so a caller must name the case where nobody checked; an Option would have collapsed them').
doctrine(a_fixture_that_avoids_the_default_is_a_fixture_that_hides_a_bug,'every test in improvise-gp/dataset.rs built its View with View::new + on_category_added, under which an underscore category lands on Axis::None. A real Workbook::new puts _Measure on Page, and the whole feature matrix collapsed there. When the code under test takes a value a constructor elsewhere produces, at least one test must use THAT constructor').
doctrine(assert_contents_not_shape,'the first version of the _Measure-on-Page regression pinned the header and the row count, and passed with the bug present: the target is _Measure first item in that fixture, so every row still had a target and only the feature columns came back blank. The shape survived while the data did not').
doctrine(a_user_facing_string_is_code,'a rustfmt pass over the multi-line literal spelling improvise-gp refusal left runs of ten spaces mid-sentence -- invisible in the source, invisible to every test that only asked whether a substring was present, and visible to precisely the user the message exists for. Assert the shape of a message read off a status bar: one line, no double spaces').
doctrine(a_printed_value_must_be_a_parseable_one,'FitOutcome::status_line spells the seed {:#x} and tells the user that reproduces the fit; FitMeasure::parse took decimal only, so the one instruction the status line gives failed on the value it had just shown. Aim the test at the round trip THROUGH the renderer, never at a hand-written literal of the rendered form -- a literal still passes when the printing changes, which is the half that breaks').
doctrine(a_guard_test_must_outgrow_the_older_guard,'the MAX_FEATURES test at twelve records passed with the guard deleted, because that document is refused anyway by fitted_formula noticing a one-hot. When a new check shares a code path with an older one, the fixture has to be past the point where the older one fires, or the test is measuring the wrong refusal').
doctrine(verify_the_mechanism_a_fixture_leans_on,'an improvise-gp fixture kept a category single-valued and claimed simplify would prune the constant one-hot, making the success path deterministic. data.rs::build_categorical pushes (0.0, 1.0) as the range of EVERY one-hot whatever the column holds, so simplify cannot know it is constant -- the tests passed on seed luck and the claim was repeated in five places before anyone read build_categorical. A fixture whose determinism rests on a mechanism elsewhere in the tree must cite the line, not the belief').
doctrine(resolving_a_name_is_not_meaning_a_column,'fitted_formula asks whether the DOCUMENT can resolve a feature name, which is right for \"can this be spelled\" and insufficient alone: a numeric category named Cost encodes to a feature named Cost, and a _Measure item of the same name makes Expr::Ref resolve to the wrong column. The role check in improvise_gp::fit asks preprocess own plan instead -- a used feature is accepted only where the Dataset column it came from is a raw Numeric MEASURE column -- so one-hots, .missing flags, date parts and category coordinates are refused by ROLE rather than by spelling. Anchored by a_category_and_a_measure_of_the_same_name_are_refused and an_empty_category_before_a_measure_does_not_break_the_role_check. No function name is given here on purpose: the symbol the original proposal cited is in no file under crates/, and a fabricated symbol is diagnosed exactly like a rename.').
doctrine(guard_the_cost_not_the_result,'the MAX_FEATURES check ran after preprocess had already materialised the rows x rows one-hot matrix it existed to prevent, so it refused the width only once the memory and the O(rows^2) work were spent. encoded_width counts what the expansion WOULD produce, from the raw table. A budget check has to run on the cheap side of the thing it is budgeting').
doctrine(a_budget_check_is_advisory_or_exact_and_must_say_which,'improvise_gp::fit has two width checks: encoded_width is a cheap preflight that approximates preprocess type inference so a rows x rows one-hot is refused before it is allocated, and Dataset::feature_names.len() is the exact one that actually enforces MAX_FEATURES. Stating which is which is what makes the approximation safe -- a drift in either direction is bounded by the exact check behind it').
doctrine(a_fixture_must_assert_it_built_the_case_it_names,'a collision test added a category named Cost and never put a Cost coordinate on any record, so the column was empty, preprocess dropped it, and the duplicate name the test existed for never occurred -- it passed on an unrelated bug. The fixture now asserts the header really contains two columns named Cost BEFORE fitting. When a fixture constructs a rare shape, assert the shape, not only the outcome').
doctrine(characterize_the_negative_when_the_positive_is_incidental,'the aggregated-cell test could not assert what the cell IS -- on that document it is Error(\"ref:Cost\") rather than a wrongly-routed number, which is a property of the shape and not a rule anything states. It asserts what the cell is NOT: the sum of the per-record predictions. That covers both outcomes and fails on the day the behaviour is deliberately changed, which is when the characterization should be replaced').
doctrine(a_link_is_a_promise,'Generated documentation must never emit a link it cannot resolve. An improvise-xxxx id whose title is not in the .beads/issues.jsonl export renders as PLAIN TEXT, and a cited source path links to GitHub only when the file exists on disk at generation time. A dead link is worse than no link for the same reason a dead ticket id is: it reads as a promise that the target exists. The generator therefore degrades to text rather than guessing a URL scheme.').
doctrine(the_link_check_is_the_build,'scripts/build-doc-site.mjs resolves every anchor it emits against every internal href it writes and exits non-zero on a break; web/assemble.sh runs under set -eu, so a dead cross-reference fails the assembly rather than shipping. It earned this on its first two runs, catching a fence-close regex that compared a three-character marker to a one-character string (every document swallowed from its first code block onward) and a list parser that ended the gotcha list at the first blank line (1 of 48 anchors emitted while the jump strip linked to 48). Both were invisible in a rendered page that looked fine, which is the general lesson: for generated documents, \"it renders\" is not evidence.').
doctrine(publish_by_manifest_not_by_glob,'A document reaches the public site only by being named in the SECTIONS manifest. A glob over context/** would publish the next working file dropped there, and what a stranger should read is not a decision to make by accident. The corollary is that every omission must be argued rather than merely happening -- context/code/doc-site.md carries the argument and is itself published.').
doctrine(add_the_check_that_could_have_seen_it,'When a review finds a defect a check missed, fix the defect AND add a check that could have seen it, then prove the new one by reverting the fix. The worked example is a nested <a> in the generated doc site: the link check, a whole-site link check and a screenshot pass all missed it because every href resolved -- the defect was outside the question that check asks. A reviewer found it by clicking a link. Reverting the fix makes the new check report 66 and fail the build. The generalisation: a green check is evidence about its own question only, so name the question before trusting it.').
doctrine(escape_tracker_text_as_an_attribute,'esc() escaping & < > but not the quote it is interpolated into title=\"...\" with let five shipped bead titles break out of the attribute, and a browser parsed the remains as junk attributes. Tracker titles are written by whoever files a bug, so a friendly corpus today is not a reason to interpolate untrusted-shaped text unescaped. Escape the delimiter you are interpolating into, not merely the characters that are unsafe as text.').
doctrine(a_destructive_default_warns_after_the_write,'bd update --notes REPLACES a ticket\'s notes and prints its warning only after the write has happened. On 2026-09-20 it destroyed 5352 characters on improvise-xf1 -- including a user-supplied correction that contradicted the note being added. Recovered from the previous .beads/issues.jsonl export in git, which is the only backup and is overwritten by the next auto-export. General form: when a flag\'s failure mode is \"already gone\", the warning is not a safeguard. Prefer --append-notes; read before overwrite.').
doctrine(parallel_actors_need_distinct_names,'bd\'s --actor defaults to $BEADS_ACTOR, then git user.name, then $USER, so a wave of agents sharing a machine shares an actor and the claim guards cannot tell them apart -- two agents claim one bead and neither is told. Exit 13 means another actor won the race; that is the mechanism working, not a failure to retry around. User instruction, 2026-09-20.').
doctrine(a_stale_gotcha_asserts_the_negation,'repo-map gotcha 17 said float equality was \"currently mixed\" and named the div-by-zero guard as the exact-comparison half, long after both had been unified on FLOAT_EQ_EPSILON. A doc that goes stale does not merely omit -- it tells the reader not to assume the thing that is now true. This is why the orchestrator doc pass VERIFIES each claim against the tree rather than applying a queued edit.').
doctrine(an_advisory_gate_is_not_a_gate,'scripts/check-comment-runs.py was written, fixed seven times, and quoted by seven agents as their definition of done while being invoked by NO workflow. A rule enforced only by the agents who agreed to it is a convention. The gate landed last, deliberately, because a gate that goes green before the work is done is the same class of lie the checker itself carried.').
doctrine(a_review_names_the_sha_it_reviewed,'A review of a superseded commit is worse than no review: it reads as a pass. So the reviewer names the exact SHA, the author does not rewrite history while a review is in flight and announces any push, and a re-review is of the delta. This matters more now that adversarial agents are the ONLY gate -- Copilot\'s quota is exhausted for the rest of 2026-09 and its request is a SILENT no-op (the mutation returns success and registers no reviewer), which is itself an instance of doctrine(an_advisory_gate_is_not_a_gate).').
doctrine(review_is_requested_not_inherited,'No PR is reviewed because it exists. Requesting review is a step in the author\'s own definition of done, ranked beside \"tests pass\", not a courtesy notification afterwards. The failure mode it prevents is the quiet one: an agent finishes, opens a PR, reports success and waits on a review that was never going to come -- indistinguishable, from the outside, from a PR waiting its turn. Note the shape repeats: an ABSENT gate and a SILENTLY no-op request both read as \"in progress\", which is an_advisory_gate_is_not_a_gate seen from the requester\'s side. The user had turned Copilot AUTO-review off, which is the actual explanation for PRs #37/#41/#42 merging unreviewed; the orchestrator had attributed that to quota, and the two are independent facts.').
doctrine(construction_is_not_loading,'A lazily-loading embedded player proves nothing by existing. asciinema-player with autoPlay:false does not FETCH its cast until the viewer clicks, so a page whose every cast path 404s constructs the full set of players and throws no page error. The agent\'s own check passed on a page broken exactly that way (./casts/ -> ./cats/); pressing play returned 0 rendered lines and four 404s. Generalises: any check on a lazily-initialised embed must trigger the load it is about.').
doctrine(a_deployed_path_is_a_grep_across_the_tests,'Moving where something is served is not a build-script change, it is a grep across the TEST surface. The landing-page inversion (user ruling on improvise-kh8) cost one line in web/assemble.sh and six navigations in harness/tests/shell.spec.js, a CI-gating job the agent did not own. Nothing about \"serve the landing page at the root\" says \"a browser test job goes red\". In this repo the harness is the surface LEAST likely to be in the mental model, and for a structural reason: it is not a workspace member and cargo test never touches it, so every habit the repo has built is blind to it. Cross-reference: the harness serves the DEPLOYED arrangement on purpose (improvise-vvl3), which is what makes it trustworthy and also what makes a layout change a test change.').
doctrine(a_guard_that_matched_is_not_a_guard_that_fired,'Watching a guard MATCH proves the happy path. A guard written to catch a SILENT failure is only evidence once the specific failure it names has been injected and it has complained BY NAME. web/assemble.sh\'s four stamped checks exist because one grep over four files succeeds when any one matches (found by Copilot on PR #24), so injecting a generic failure would not test them -- the fault to inject is one broken substitution, and the evidence is that the right guard names itself. Same shape as the vacuous raw-string fixture in scripts/check-comment-runs.py: the orchestrator injected a fault, the self-test stayed GREEN, and the fixture turned out to prove nothing because a plain-string fallback reached the same verdict.').
doctrine(an_injection_tests_the_prose_as_well_as_the_check,'A guard that carries an explanatory comment has TWO things that can be wrong, and injecting its fault tests both. web/assemble.sh\'s stamped comment says each reference \"names itself when it is the one missing\". Injecting one broken substitution confirmed exactly that. Injecting TWO showed stamped exits on the first, so only one is ever named -- a sentence that is true as written and misleading as read (improvise-x4wv, filed rather than fixed: it fails safe, and the guard\'s shape came out of a Copilot finding on PR #24). The agent\'s own framing is the part to keep: both times my instinct was to verify the happy path and call it evidence -- the players constructed, the guards matched; both times the injection was what turned it into a check. Across wave 2 the happy path was checked and called evidence THREE times by three different actors and all three were caught by injection and by nothing else, which is why force_the_failure has to be a standing rule rather than a reminder.').
doctrine(a_reasoned_claim_feels_like_a_measured_one,'Self-reported unprompted by the WU-N agent on PR #49, and kept because the failure is structural rather than careless: at the moment of writing, a claim you reasoned and a claim you ran feel the same from the inside. Three in one PR -- an ungated AddItem reset caught by reading its own diff rather than by a test (1312 green throughout); a benchmark share quoted from a single run that a second run contradicted (64% vs 45%); and a verification command handed to the reviewer as printing nothing, which printed 94 lines, discovered only because the agent ran it AFTER sending. force_the_failure covers the test side; this is the reporting side. Run the command before quoting its output; quote a measurement as a range over runs and say which digits do not reproduce; and label an argued claim as argued so a reviewer can weight it.').
doctrine(a_specialised_fast_path_can_be_the_only_path,'eval_agg had a fast path for Expr::Ref and a fallback that silently ignored its argument: for ANY inner expression that is not a bare Expr::Ref it narrowed nothing, summed the raw cells matching the partial key and DISCARDED THE EXPRESSION, so SUM(Revenue - Cost) summed Revenue AND Cost together. Every formula anyone had written used the fast path, so the fallback was never exercised and never wrong-looking, and no test anywhere covered it -- improvise-4zl7\'s own framing described a more careful bug than the one present. When a function special-cases the common shape, ask what the general branch actually DOES with the input it was given, not whether it returns something plausible.').
doctrine(a_negative_assertion_must_show_its_event_reachable,'A test denying an event must demonstrate that event is reachable on that exact code path, not a sibling path and not the rule\'s unit test. Prefer a guard that makes the vacuous case fail by name. Ask whether the event was EVER possible on this path, not whether it is pending now: a correct implementation may already have cancelled it, which is usually the property under test.').
doctrine(a_premise_guard_is_validated_in_both_directions,'A guard that closes a vacuity must be shown to fail on the vacuous path AND not to fail on the passing path. Only the first is obvious and only the first is what a reviewer asks for. Worked example: a watchdog premise-guard spelled armed() (armed now) passes the vacuity test and breaks the correct case, because a correct page has already disarmed by the time the check runs; timesArmed() > 0 (ever armed) is right. The distinction appears only when you build the guard and watch a green test go red.').
doctrine(measure_the_property_that_actually_moved,'A measurement that cannot distinguish the two states is not a measurement, and a measurement of the wrong property is not one either, however precise. This repo\'s signature defect is a test that passes while the thing it names is broken, and it arrives through the INSTRUMENT at least as often as through the assertion. Instances. getBoundingClientRect().height reports a height for content-visibility-hidden children, so a nav-collapse probe read 26 visible links in BOTH the collapsed and the expanded state and would have passed the build whose DESKTOP sidebar was collapsed and unusable; checkVisibility({contentVisibilityAuto:true,visibilityProperty:true}) discriminates. Heading text overflows its box without widening it, so getBoundingClientRect reported every element inside the viewport while seven doc-site pages overflowed at phone width -- only per-element scrollWidth names the culprit. A width measured on a display:block label could not see an inline glyph and accused working code. A wall clock absorbed a contaminated tree without a trace where an arithmetic identity would not have. A CI watcher concluded on \"0 checks pending\", which reads identically for \"all finished\" and \"none registered yet\", and so reported a fresh push settled instantly -- a terminal state needs a non-zero total AND the expected head. And the sharpest case is the one where the instrument is sharp and still aimed wrong: a checkbox driving the doc-site\'s collapsed phone nav carried hidden (= display:none), which removes it from the tab order AND the accessibility tree, so at <=900px the navigation was mouse-only, and the probe that \"verified\" that fix counted VISIBLE nav links -- complete for a mouse, silent on whether any non-mouse route exists. Ask what the defect would DO to a user, then pick the instrument that observes that: here tab order, .focus() and the accessibility snapshot, measured on BOTH sides of the media query the regression lives in. The check is the same one applied to a test -- injure the thing being measured and confirm the number moves.').
doctrine(an_aged_fact_is_cured_only_by_re_asking,'An instrument failure and an aged fact have no cure in common. Picking a better instrument fixes the first permanently; a fact verified once and believed later can only be re-asked, every time. Worked examples from 2026-09-20: a mergeable status verified in review round 1 and still believed in round 6, through two rounds of CI silence, while the branch sat conflicting; and a knowledge-base clause count taken from a working tree six merges behind, which would have been reported as evidence for a write that deleted three clauses.').
doctrine(an_out_of_band_write_desynchronises_the_beads_export,'AGENTS.md says never hand-edit or git checkout .beads/issues.jsonl and attributes the damage to the post-checkout hook. That attribution is INCOMPLETE: resolving a merge conflict on that file with git restore, which fires no hook at all, desynchronises export-state.json identically. The mechanism is ANY out-of-band write, not the hook. The failure is silent in the worst direction -- bd list succeeds and re-exports nothing, git status reads clean, and issues exist in Dolt and nowhere else; three tickets were in that state until an explicit bd export -o reconciled it. The recovery is bd export -o, never bd list. Standing rule: any git operation touching that file is followed by bd export -o.').
doctrine(a_goal_runner_exits_zero_unless_halt_is_reached,'trealla -g runs a goal and exits 0 unless halt/1 is REACHED. Measured on 3.10.3: -g \"fail\" and -g \"throw(boom)\" both exit 0, and so do \"fail,halt\" and \"throw(boom),halt\", because a goal that fails or throws never reaches the halt beside it in the conjunction. -g \"halt(2)\" exits 2, so halt itself works -- the DEFAULT is the hazard. Any script driving it must guarantee a halt on every path: (catch(main,E,(write(user_error,E),nl(user_error),halt(2))) -> halt(0) ; halt(1)). What this prevents is not a hang but a SILENT SUCCESS -- an \`if ! tpl ...\` reads a crashed run as a clean one and proceeds -- so it is force_the_failure applied to the runner rather than to the code. Related: an uncaught error otherwise falls back to the REPL, which hangs on a terminal and exits 0 without one, so scripts/kb closes stdin as a backstop.').
doctrine(kb_is_maintained_by_scripts_kb,'prolog-sessions/kb.pl is written by scripts/kb, which reads clauses as DATA and never consults them, so adding a clause costs the clause. This doctrine was named kb_is_maintained_through_the_mcp and the NAME was the claim that went false -- the same repair opfs_is_run_but_bindings_is_not needed. The MCP was retired because loadProgram REPLACES a whole predicate: adding one clause meant re-sending every existing clause of it through an agent context window, measured at ~113KB read and ~113KB written to deliver ~25KB, and a short payload silently drops the rest. Three residues of it remain true and are why nobody should go back: saveSession re-adds eight host_rpc shims (AGENTS.md enumerates seven -- js_eval_json is missing, so a grep built from that list is one case short), saveSession reserializes the whole file, and assertz raises permission_error(modify,static_procedure) against a consulted predicate so there is no incremental route through it. Reading is unaffected: loadSession at session start is still how a session gets the model in hand. See context/code/kb-tool.md.').
doctrine(documentation_is_operational_and_history_lives_in_the_kb,'User ruling 2026-09-20: documentation should not spend much on history -- that is bloat. Prose docs (AGENTS.md, skills, context/code) say what to DO, with only the one line that keeps a rule from looking arbitrary. The worked examples, the dates, the war stories and the \"we tried X and it failed this way\" belong in doctrine/2 and decision/3 text, which is what those predicates are for and where a reader goes deliberately rather than while looking for a command. The test to apply to a paragraph: would someone following this document need it to act correctly? If it only explains how the rule was discovered, it is a clause, not a doc.').
doctrine(an_agent_posting_to_github_says_so,'User instruction 2026-09-20: anything an agent posts to GitHub goes out under the repository owner account, so every PR comment, review and issue body opens with a line naming the model and that it is an agent posting in his session. A PR body 🤖 Generated with Claude Code footer already does this; comments carry nothing unless it is added, and an unattributed comment reads as the owner own words to every reader -- including reviewers deciding how much weight to give it. The same applies to anything else published outward under his identity.').
doctrine(a_write_must_not_be_able_to_disarm_its_own_gate,'scripts/kb validates a candidate by CONSULTING it and running kb_consistent, so the thing being checked gets to influence the check. Three ways that was exploitable, each demonstrated on PR #68 before it was closed: a delta of \`:- halt(0).\` -- a directive is callable, so a callable test lets it through -- exited the check with success before kb_consistent ran; REMOVING kb_consistent made it throw existence_error, and tpl exits 0 when a goal throws before reaching halt, so the gate passed by not existing; and REPLACING it with the fact kb_consistent. passes forever after. Fixes: refuse directives and queries by shape, catch in the validation goal so a throw exits non-zero, and refuse writes touching the gate predicates unless KB_ALLOW_GATE makes it deliberate. The general form: when a checker consults its own input, ask what the input can do to the checker, not only what the checker says about the input. The gate predicate list is hand-maintained, which is its residual weakness -- a new one is unprotected until named.').
doctrine(a_read_validate_write_sequence_is_a_lost_update,'scripts/kb read the knowledge base, merged, validated and moved the result into place, with nothing between the read and the move. Two agents doing that concurrently -- which is ordinary here, waves run agents in parallel -- both read the same file, both validate, and the later move silently discards the earlier addition. Exactly the class this tool was built to remove, reintroduced one level up in the tool itself. Closed by stamping the file content at read time and refusing to commit if it changed, plus an mkdir lock (flock is absent on macOS) so the collision is rare rather than merely loud. The stamp is the part that matters: a lock prevents the common case, a compare-and-swap is what makes an uncommon one impossible to lose silently.').
doctrine(design_against_the_model_before_implementing,'AGENTS.md has always said Prolog is a design tool for reasoning about tickets, and it was the least-used line in the file: kb.pl was written retrospectively. \`scripts/kb check --with <design.pl>\` closes that -- proposed facts are merged into a throwaway candidate and kb_consistent runs over the union, so a design can be refused before code exists. The model already DERIVES rather than merely records: illegal_dependency, may_use, depends_transitively, portable, snapshot_safe, view_state_admits, record_obligation, needs_runtime_check, and eleven kb_error classes including layering_inverted and field_misplaced_in_view_state. Demonstrated on three proposals in one sitting: one added edge (improvise_core depending on improvise_command) produced SIX derived layering violations through the transitive closure, which no author would have enumerated; a proposal to declare decision/3 functional was refused with conflicting_values naming improvise-907, correctly, because one ticket carries several decisions; and a mis-ordered layer/2 argument was named as layer_names_unknown_crate rather than silently accepted. What makes this worth doing rather than decorative is the same rule as everywhere else -- the model must be able to REFUSE, and you must watch it refuse. A design language nothing can reject is documentation with parentheses.').
doctrine(a_key_rule_must_ask_whether_the_relation_is_multi_valued,'scripts/kb keyed every clause on the functor plus all arguments but the last, which is right for doctrine(Name,Text) and wrong for any RELATION: crate_depends_on(A,B) then keyed on A, so a crate depending on two crates read as one key twice and was unaddable. Found by trying to model a proposed crate, not by any test -- the tool had only ever been pointed at single-valued facts. The fix cannot read the KB own functional/1, because that check keys an arity-3 predicate on its FIRST argument, which is right for crate/3 and wrong for decision/3 where one ticket holds several decisions under different names. So key_arity/2 is a hand-maintained table and anything unlisted keys on the WHOLE head, which is the safe direction: an unlisted relation accepts many clauses and only an exact repeat is a duplicate.').
doctrine(a_local_shell_is_not_the_ci_shell,'zsh ABORTS on an unmatched glob and bash leaves it as a literal, so a CI step written and probed in zsh can fail in a way no developer shell can reproduce. node --test web/tests/*.test.js under bash therefore globbed the literal itself, matched nothing, reported 0 tests and exited 0 -- both node steps gated nothing the moment their directory moved (improvise-vz54). Local shell vs CI shell is a fault-injection axis in its own right: probe a CI line under the shell the workflow names, not the one you are typing in. The fix moves the glob into scripts/node-test.sh so the expansion is the script\'s and can be refused.').
doctrine(a_convention_that_keeps_a_checker_quiet_is_the_one_that_blinds_it,'context/test-evidence used #### for browser tests precisely BECAUSE --check-evidence resolved names against Rust fn names only, so a ### heading naming a Playwright test would be reported as an orphan. The workaround made every browser entry invisible, and a deleted test left its heading promising coverage for a whole release (improvise-5ray). Cure: collect ### and #### alike and decide by SHAPE -- a bare identifier is a Rust test, a quoted string is a JS test, anything else is prose naming nothing. When a checker forces a convention on the thing it checks, ask what that convention hides.').
doctrine(a_refusal_is_evidence_only_when_it_is_the_one_you_named,'Two kb-test cases passed against a tree with the fix they guarded reverted, because a DIFFERENT guard refused the same delta and the assertion was only \"fails, and the kb is unchanged\" (review of PR #69). A tool with several guards will usually refuse a broken delta somehow, so \"it was refused\" is nearly free and says nothing about the fix under test. Assert the REFUSAL MESSAGE, or construct a case only the fix can catch -- and prove it by reverting that one fix and watching the case fail. The same trap in reverse: a case asserting an OUTCOME (the add lands and every sibling survives) can discriminate where a message cannot, because what a wrong key destroys is the outcome.').
doctrine(a_weak_check_and_a_strong_one_agree_until_the_defect,'Five instances in one wave, and they are one mistake rather than five: a guard watching KeyOutcome::Upstream but not Ignored, clippy with versus without -D warnings, a grep for \"warning[\" versus a bare \"warning:\", toBeVisible versus toHaveText, and a grep for test( versus node own pass count. In every case the weak check and the strong one AGREE ON EVERY INPUT ANYONE TRIED and diverge only on the input that is also the defect -- which is why the weak one survives review and why running it proves nothing. The common cause is a check written against the failure you IMAGINED rather than the outcome the USER SEES. The repair is the same each time: assert over the observable -- is anything on the screen, did the process exit non-zero, did the document move -- rather than over the mechanism, which variant, which log prefix, which substring. Two things in this wave got it right and are the pattern to copy: furnished() seats the panel cursors off the edge so move-panel-cursor can answer the question rather than the test agreeing not to ask it, and scripts/node-test.sh reads node own report instead of grepping for test(.').
doctrine(every_wave_agent_gets_its_own_worktree,'Dispatching parallel agents without worktree isolation puts all of them in the SAME working tree. Observed 2026-09-20 with four agents: one git status showed one agent effect.rs beside another harness/build.sh and a third scripts/node-test.sh, so any commit would have swept up half-finished work from three units and any checkout would have destroyed it. The tell is cheap -- run git status in the primary tree after dispatch and see whether it holds files from more than one unit. Recovery is per-agent: create a worktree, copy across only the files that agent can PROVE it touched, then git checkout -- those files in the primary tree; an agent that guesses which files are its own makes it worse. Ambiguity is real -- two units can legitimately both be in crates/improvise-protocol -- so the instruction is to check an edit history, not to infer from the ticket.').
doctrine(the_orchestrator_delegates_substantial_work,'User instruction 2026-09-20: substantial work goes to an agent in a worktree rather than being done in the orchestrator, and repeated tasks become a script or a skill rather than being retyped. The obvious reason is that heavy orchestrator work blocks every sub-agent waiting on it. The non-obvious one is that orchestrator work lands in the SHARED primary tree, so it collides with whatever the agents are doing there -- an orchestrator fixing a bug in scripts/ while an agent owns scripts/ is a conflict the agent did not agree to. Corollary: a brief retyped by hand drifts between dispatches, which is what the wave skill exists to stop.').
doctrine(a_message_assertion_is_a_uniqueness_claim_about_the_whole_vocabulary,'A test asserting WHICH refusal fired -- rather than that something refused -- is only as strong as that message being unique across every refusal the code can emit. The moment two share a phrase it degrades back to \"something refused\", the exact defect it was written to repair, wearing a more convincing sentence. So when an assertion moves onto a message, enumerate the whole error vocabulary: on PR #69 the reviewer listed all seven REFUSED strings in kb-merge.pl and confirmed each asserted phrase appears once, in the refusal its case names. Where no message can discriminate -- a wrong has_key is always caught by check_total, the backstop -- assert the OUTCOME instead, and guard the backstop separately or a case can lean on it while claiming to test something else.').
doctrine(run_the_gate_ci_runs,'A local gate differing from CI in its flags or its output filter can only fail in directions the defect is not. On PR #71 the author ran clippy WITHOUT -D warnings and grepped for a lint name in brackets, which cannot match the bare \"warning:\" unused_imports emits, so the check printed zero and read clean while CI went red. Worse, the repo own code-review skill specified the same flagless invocation, so it was the INSTRUCTION rather than one person shell mistake -- a reviewer following it would report clean and be wrong. Invoke the gate exactly as the workflow does, flags and all.').
doctrine(an_observation_correct_about_what_changed_can_be_wrong_about_when_it_is_read,'improvise-pdvm/75ld replaced a DECLARED classification with an OBSERVED one, which is right. The cost landed on WHEN the observation is read: comparing document_config once AROUND the batch is correct about what changed and wrong about ordering, because Save clears dirty INSIDE the loop, so [SetNumberFormat, Save] saved and then re-raised the flag. The fix is position, not suppression -- the comparison moved into the loop, using |= so it only ever sets. The asymmetry that decides it: nothing ever CLEARS layout_version, so an end-of-batch comparison is right there, while dirty has an effect that unsets it mid-batch. The discriminating probes rule out BOTH wrong fixes: [SetNumberFormat] alone must still be dirty, or LayoutGeometry has been demoted and the document never saves; and [Save, SetNumberFormat] must be dirty, or the fix is a blanket never-dirty-after-save.').
doctrine(a_siblings_unmerged_pr_is_invisible_to_bd_search,'AGENTS.md requires a bd search before filing. In a parallel wave that is not sufficient: a fix living in a sibling agent UNMERGED PR is in no tracker state at all, so the search correctly returns nothing and the duplicate is filed anyway. Observed 2026-09-20 -- the tooling agent filed improvise-v5pc to restore a deleted help assertion while the harness agent was landing exactly that in PR #72. Neither agent erred. Check OPEN PRS as well as the tracker, and have the orchestrator reconcile filings against merged work at wave end rather than trusting each agent to have seen the others.').
doctrine(a_hard_reset_discards_knowledge_written_to_the_working_branch,'The orchestrator wrote doctrines to kb.pl on local main, pushed each as a branch, then ran git reset --hard gh/main to resync -- twice -- and silently discarded six clauses that existed only in the unmerged local commits. Nothing failed: kb_consistent passed on the reduced file, the counts simply read lower, and the loss was found only by grepping for clause names remembered from the session. Two rules follow. Write a fact ONCE, on the branch that will carry it to main, not on a local main that is going to be resynced. And after any reset or force-move of a branch holding knowledge, re-count the predicates and diff clause NAMES against what you believe you wrote -- a total that merely looks plausible is the same non-evidence a passing test count is.').
doctrine(a_checker_that_reads_headings_cannot_see_a_contradicting_body,'check-evidence validates that a heading names a test that exists. It says nothing about the PROSE under the heading, which is what a human actually reads. Demonstrated on this repo 2026-09-20: renaming a retired entry to the test that had been restored made the checker green while the body three paragraphs down still said \"The test no longer exists\" and \"nothing replaced the assertion\" -- one entry making mutually exclusive claims, and the gate could not see it because the gate was never about that. Found by review, not by the checker. The general form is that a mechanical check licenses exactly the claim it makes and no neighbouring one, so a rename is a read of the whole entry rather than an edit of its first line -- a_fact_stated_twice_is_changed_by_grep applied WITHIN a document rather than across them.').
doctrine(a_cost_note_names_the_half_the_author_was_thinking_about,'Twice now a reviewer has been asked to check a claim of the form \"X is now O(n) where it was O(1)\", and both times the interesting answer was not whether the claim was true but WHICH SIDE OF IT A READER WOULD ACT ON. On PR #76 the author wrote that the cost is \"O(cells) per CellData OR LayoutGeometry effect, where it was per LayoutGeometry effect\" -- which reads as though CellData is the newly burdened path. Measured at 18,000 cells it is the reverse: CellData was ALREADY O(cells) through record_fingerprints, so widening it is a constant factor (+27%), while LayoutGeometry went from O(config) to O(cells + config) and got 33x slower, 78us to 2.63ms, on the held-key-repeat operations. Not dishonest -- the author was thinking about WHICH EFFECTS NEWLY QUALIFY rather than WHICH PATH NEWLY CHANGED COMPLEXITY CLASS, and those are different questions with different answers. A cost note is worth a measurement precisely when the author can say confidently which half is worse, because that confidence is what stops anyone measuring.').
doctrine(name_the_test_that_fails_when_only_this_edit_is_reverted,'A repair is usually N edits, and the failure mode is that N-1 are guarded while the last is explained in a COMMENT instead. Three independent reviews in one wave found the same shape: improvise-mai9 (the helper was tested, the caller that actually rendered was not), improvise-0x9u (preventDefault and the close/dismiss split were both tested, the drawn-vs-seed restore was not, and reverting it shows 18400 where 18,400 belongs on the shipped demo), improvise-o31u (the phone arm was measured, the desktop arm was justified by a mechanism measurement contradicts). The unguarded edit is reliably the one the author understood BEST -- which is why it carries the most careful comment, and why it felt covered: the comment is doing the work the test should, and it reads as evidence because it is the most thoughtful thing on the screen. The check is per-edit, not per-fix: for each distinct edit, name the test that fails when ONLY that edit is reverted. A line with a comment and no answer is the line to inject.').
doctrine(a_stale_list_of_what_is_missing_is_worse_than_no_list,'repo-map gotcha 24 named TransposeAxes and CycleAxis as the only effects calling reset_cursor, and named SetAxis, HideItem, ShowItem, ToggleGroup, TogglePruneEmpty, AddCategory, RemoveCategory, AddItem and RemoveItem as renumbering the grid WITHOUT resetting. improvise-9mo9 had closed that the day before: all of them reset, thirteen in total. So the paragraph named as ABSENT the exact set that was by then PRESENT, and read as a survey of outstanding work rather than as history -- strictly worse than saying nothing, because it invites an agent to re-fix what is fixed. A list of what is missing decays in the one direction that looks like a to-do. Where the set is derivable, publish the DERIVATION beside it: awk over impl Effect blocks for reset_cursor, brace-matched rather than grepped.').
doctrine(two_counts_from_two_derivations_are_not_one_fact,'Thirteen effects call reset_cursor and thirteen declare Model(LayoutGeometry). The counts are equal and the SETS ARE NOT: CreateView, DeleteView and SetNumberFormat declare the kind and never reset; SetPageSelection resets only conditionally. Equal length invites reconciling one list to the other, which silently changes what one of them means. Same shape as the wasm bundle being seven crates while its CI gate covers eight -- neither number is the other one typo.').
doctrine(verify_the_claim_you_are_about_to_publish_as_the_correction,'While correcting repo-map gotcha 24 for being stale, the orchestrator asserted from a reviewer report that CreateView, DeleteView and SetNumberFormat declare LayoutGeometry and never reset the cursor. Measuring it before merging found the claim TRUE but UNDERSTATED: the sets differ in BOTH directions, three each way -- AddItem, AddItemInGroup and RemoveItem reset while declaring CellData. And the orchestrator naive awk reported fourteen LayoutGeometry effects, picking up HelpPageSet, which is the exact artefact the same document already warns about two paragraphs away. A correction is a claim like any other and decays the same way; the person writing the fix is the one least likely to check it, because they have just read the evidence and feel current.').
doctrine(a_careful_comment_is_weak_evidence_that_anything_checks_it,'The companion to name_the_test_that_fails_when_only_this_edit_is_reverted, and the half that tells you WHERE TO LOOK: a comment explaining why something is correct is weak evidence that anything checks it, and the more carefully argued the comment, the more likely it is standing in for a test. Five instances in one wave, found by two reviewers independently. improvise-0x9u drawn-vs-seed: a two-line comment explaining that restoring seed would leave 1200 where 1,200 was -- and reverting the line left the whole suite green while the shipped demo showed 18400. UNCALLED in wasm_surface.test.js: a doc comment justifying every exemption, silent on the fact that callsOn is a source scan and cannot see a call inside a function nothing calls. The improvise-cl7y size table: prose that reads as measurement throughout while one column is a gzip -9 proxy for brotli. declined() view-back: a uniform user-facing sentence that erased a real distinction between not-built and cannot-be-built-without-addressed-commands. improvise-o31u: an evidence entry naming two faults, neither of which makes the test fail. The comment reads as evidence because it is the most thoughtful thing on the screen, which is exactly the illusion.').
doctrine(a_prepare_step_is_not_evidence_until_deleted,'A test-fixture prepare step is claimed load-bearing far more often than it is. Measured 2026-09-20 on every_layout_geometry_effect_is_visible_to_the_narrow_fingerprint: of four prepare steps documented as \"the reason the test is not vacuous\", only DeleteView and ShowItem are. ToggleGroup passes without one because ToggleGroup::apply calls toggle_group_collapse UNCONDITIONALLY -- the membership check gates only reset_cursor -- and TransposeAxes step is literally a no-op, because View::on_category_added already assigns the first regular category Row and the second Column. The check is cheap and nobody runs it: delete each prepare in turn and require the case to fail. A prepare documenting realistic input is fine; a prepare DESCRIBED as carrying the test and not carrying it is an entry that will be cited. Note the orchestrator brief asserting the replacement claim was itself wrong on two of the four, so this is not a failure of the original author alone.').
doctrine(a_precondition_asserted_after_the_act_asserts_nothing,'An assertion naming a fixture precondition must run BEFORE the code under test, or the code under test establishes it. Proven 2026-09-20 on selecting_the_page_already_selected_invalidates_no_cursor: \"the fixture was not on Q1\" ran after a SetPageSelection to Q1, so it could never see a fixture that started elsewhere. The property was still guarded -- a Q2 fixture failed -- but at a DIFFERENT assertion, under a message about renumbering. That split is the failure mode: the guard survives, the explanation drifts, and the next reader trusts the sentence rather than the assertion.').
doctrine(an_evidence_entry_that_measurement_contradicts_is_worse_than_none,'context/test-evidence records WHY a test can fail, so a mechanism stated there is load-bearing prose and will be cited. Proven 2026-09-20: the entry for every_layout_geometry_effect_is_visible_to_the_narrow_fingerprint claimed a dirty=false clear stopped the preparation satisfying the assertion. Removing the clear AND blinding document_config to 0 left all THIRTEEN cases reading false -- dirty is raised only inside App::apply_effects and no prepare runs an effect, so no preparation can set it. Nothing checks an evidence claim against the code: check-comment-runs.py --check-evidence verifies only that each heading names a test that exists. Re-run an evidence entry injection when you touch the test it describes. Probing this particular test needs the assert replaced by a print, because the loop aborts at case 1 under any injection blinding document_config -- which is probably how the wrong claim survived.').
doctrine(a_grammar_guard_must_be_generatable,'A pest rule whose only guard is a negative lookahead is a rule the grammar-walking generator can violate: the generator reads the grammar through pest_meta and SKIPS PREDICATES, so a caret-WHERE followed by !bare_char emitted WHEREa=\"\" and generated_formula_parses went red. An explicit separator (sp+) is generatable where a lookahead is not. This is why WHERE took IS\'s sp+ rather than the !bare_char improvise-r08u proposed, at the cost of one accepted spelling: a category may no longer abut the keyword. Applies to improv.pest equally, and constrains every future change to either grammar in this repo.').
doctrine(a_roundtrip_equality_is_blind_to_a_loss_both_sides_share,'fmt(parse(x)) == fmt(parse(fmt(parse(x)))) tests STABILITY of the printed form and says nothing about PRESERVATION. Demonstrated on improvise-349m: injecting a merge that DROPS a duplicate section left the whole improvise-io suite green at 2000 proptest cases while an item and its group line vanished from the document, because both operands lose the same thing. Main was unstable-but-lossless and the test could not tell that from stable-and-lossy. A preservation assertion must run BEFORE the equality. The sharpest form of check_the_checker yet seen here: the fault that survives is the one that changes BOTH OPERANDS.').
doctrine(could_not_reproduce_and_does_not_reproduce_are_different_claims,'Filing the second having earned only the first is how a real defect gets closed. On improvise-gfvj an agent concluded SortData does not reorder; the mechanism was real, and the cause was that ToggleRecordsMode pushes effect::SortData as its FIRST effect, so any records-mode fixture built by pressing R has a pre-sorted store BY CONSTRUCTION. The failure to reproduce was a property of the fixture, not of the code. Before writing \"does not reproduce\", name what would have to be true for the bug to exist and check THAT -- a fixture that cannot reach the condition reports the condition absent. Sibling of a_benchmark_fixture_that_cannot_reach_the_slow_branch_measures_the_fast_one.').
doctrine(a_script_with_two_modes_has_two_gates,'scripts/check-comment-runs.py runs the comment-run cap bare and the evidence cross-check under --check-evidence, and NEITHER MODE RUNS THE OTHER. Every wave brief this campaign, and the gate list in the code-review skill, named only --check-evidence -- so the cap was effectively unrun locally and caught only by CI. It caught one: an enum inserted between Model doc comment and its derive, leaving the enum carrying the comment and Model with none. When a gate grows a flag, the unflagged invocation stops being run by everyone who copied the command, and a gate nobody invokes is indistinguishable from a gate with nothing to catch.').
doctrine(an_injecting_reviewer_needs_its_own_tree,'An adversarial reviewer given leave to inject faults RACES THE AUTHOR if they share a worktree: on wave F the reviewer subagent injections landed in the author tree mid-gate and the author own run reported a failure for a fault it had not written. Everything was restored cleanly, but the diagnosis cost real time and could have been read as a genuine regression. A reviewer that injects gets its own worktree or a scratch copy, always. Related and separately costly: the session SCRATCHPAD IS SHARED across wave agents, and one agent overwrote another pr-body.md mid-task -- unique filenames, or a per-agent subdirectory.').
doctrine(who_may_connect_is_two_questions,'Ask of any access control whether it constrains the CLIENT or what may DRIVE the client. A websocket handshake is exempt from the same-origin policy, so an address allow-list, a private network and loopback binding all answer \"may this client reach the endpoint\" and answer YES to a cross-site hijack: the request genuinely comes from an allowed client, driven by a page the user never intended to trust. Only Origin separates them. Loopback is the LEAST protected case, having no proxy in front of it at all -- the intuition that it is the safe default is exactly backwards (improvise-kc7n).').
doctrine(a_line_count_in_a_document_is_a_dated_snapshot,'repo-map File Inventory line counts were audited 2026-09-21: 26 of 61 resolvable entries had drifted past 10 percent, several by a third. They are useful as \"is this file big\" and useless as a figure to cite, which is how false precision enters a document that is otherwise carefully checked. Refreshed and labelled with the date and the method. Two traps found while doing it: a naive path-walking refresh resolves a bare name like lib.rs or render.rs against the FIRST matching crate and silently rewrites the wrong number (it reported lib.rs 561 -> 8), and the ws-server/ and improvise-worker-server/ entries use a shorthand prefix no such walk resolves at all, so they drift silently and must be checked by hand.').
doctrine(measure_the_obvious_fix_before_reporting_it_as_the_fix,'On improvise-mcq4 the obvious narrowing -- fingerprint only the active view rather than every view -- was the ticket own first option AND the orchestrator suggestion AND the author plan, and implementing it moved 12.41ms to 12.56ms: NOTHING. The per-view multiplier was never the cost; two EXPENSIVE builds were, and the bench second view was a fresh one with everything on Axis::None, costing nothing to build. It would have been reported as the fix if the author had not re-measured after implementing. The real fix was unrelated: at batch start self.layout ALREADY IS the active view pruned layout, so hash it instead of building a second (4.1x to 1.9x). A fix that three people agree is obvious has had its plausibility checked and not its effect.').
doctrine(a_clean_merge_of_two_mechanisms_leaves_both,'When two branches replace one mechanism with two different ones, the dangerous file is the one that does NOT conflict. On PR #78 app.rs conflicted loudly (two hunks, resolved by hand) while effect.rs AUTO-MERGED CLEAN and left #76 new per-effect SetPageSelection reset standing beside #78 whole-grid observation: two complete live cursor-invalidation mechanisms, 1435 tests green, nothing failing. A conflict marker is a request for attention; its absence is not a statement that none is needed. After any merge where both sides touched the same SUBJECT, ask what each side did rather than whether git could combine the texts -- git merge-tree reporting no conflict answers \"can these texts be combined\", never \"does the combination do what both sides meant\".').
doctrine(a_test_name_comes_from_the_runner_not_the_source,'Scraping source for test names cannot tell a test from text that looks like one. Four shapes defeated check-comment-runs.py --check-evidence, all \"evidence for a check that never runs\": a #[test] fn inside a raw string literal, one behind an unenabled cfg feature, one behind cfg(target_arch = \"wasm32\"), one behind #[ignore]; a reviewer added a fifth, inside a block comment. Injecting the four made the checker print \"543 entries, all name a test that exists\", exit 0. Names now come from cargo test --workspace -- --list minus its --ignored twin, and they are FULL MODULE PATHS: six leaf names collide in this workspace and two were cited, so a heading was satisfiable by a test in another module answering another question -- renaming the one an entry described left the checker at exit 0 with the namesake answering for it. An ambiguous leaf is REFUSED naming its candidates, never warned. NEITHER HALF SUFFICES: rename one of a colliding pair and the leaf is unique again and silently resolves to the survivor, so qualification is the fix and the refusal is only what forces someone to write it. Corollary: once defined comes from the runner, a FIXTURE self-test cannot discriminate the source shapes, because the source beside the fixture is no longer read -- the evidence must be a real-tree injection (improvise-rm4s). Still open: --list does not name the binary, so two crates sharing a module path are indistinguishable (improvise-sxsf).').
doctrine(a_prose_demotion_is_read_not_gated,'context/test-evidence/ headings are classified by SHAPE: a bare identifier or module path is a Rust test, a quoted string a JS test, anything else is prose. So an orphaned heading can be retired by rewriting it with a space and a dash, and no gate can check whether the prose is honest -- the question is whether a sentence is true. The unsafe direction fails LOUDLY (prose that looks like an identifier is a claim and is reported as an orphan), and THE MECHANISM IS THE DIFF: git diff context/test-evidence/ shows a \"### identifier\" line becoming prose, in the diff every reviewer already reads. The printed prose count is a CONVENIENCE, NOT A GUARD -- nothing stores the previous count and nobody diffs two CI logs, so claiming a retirement \"moves a printed number\" would assert a detectability the check cannot support, which is the defect class a_check_adjacent_to_its_question names. The live instance was PR #69 demoting a heading while PR #72 restored its coverage under another name (improvise-9za9).').
doctrine(a_convenience_flag_that_is_more_permissive_is_a_bypass,'--rust-tests took one file and called the name extractor with ONE argument, so the --ignored subtraction never ran, while printing a success line BYTE-IDENTICAL to the honest path. A flag strictly more permissive and indistinguishable in the log is a bypass, not a convenience: the CI log cannot say which path ran, so the weaker answer is unattributable. Two remedies, both taken: the pair is mandatory (one alone exits 2, saying which rule it would drop), and the success line NAMES ITS SOURCE. Found by review of PR #83 -- in the same PR that rewrote the mode, which is the point: writing the doctrine down does not exempt the writer.').
doctrine(a_check_adjacent_to_its_question,'The characteristic defect here is not a wrong line; it is a check ADJACENT to the thing it names. Three instances in one campaign: set_panel_open_and_cursor answered \"is the flag true\" where it meant \"did the effect set it\", and once the launch default supplied true the effect could be a no-op with 1391 tests green; its first repair folded [false,true] and asserted the final state, which a reducer that folds nothing also satisfies; and git merge-tree reporting no conflict answers \"can git combine these texts\", never \"does the combination do what both sides meant\". Remedy shape: NAME THE QUESTION YOU ACTUALLY MEAN, THEN FIND AN INPUT THAT SEPARATES IT FROM ITS NEIGHBOUR -- assert the direction the default cannot supply, inject a ONE-DIRECTIONAL fault, materialise a merge and check that a count which should MOVE actually moved. Prose in context/design-principles.md section 7 (improvise-84wp).').
doctrine(a_narrowing_optimisation_owes_the_correctness_test_its_predecessor_passed,'improvise-mcq4 narrowed an all-views frame observation to the active view alone and lost a multi-client invalidation that no test named: a CellData write pruning a row out of ANOTHER client view became invisible, because view_shapes is config-only and the narrowed frame builds only the host view, so the bystander is never sent ResetCursor while its grid loses a row. That is gotcha 24 strictly-worse-than-nothing case -- the diff now believes the bystander is current, so it stays stale PERMANENTLY rather than until its next scroll. The sting: the narrowing had already been MEASURED AS BUYING NOTHING (12.41ms to 12.56ms) and was kept only as \"the honest shape\". So a change with no measured benefit carried an unmeasured cost. Before narrowing what an observation covers, write the probe the WIDER version passes and require the narrower one to pass it too; a narrowing is a claim that the removed breadth was unnecessary, and that claim is testable.').
doctrine(a_count_in_an_assertion_is_not_a_totality_guard,'assert_eq!(cases.len(), 13) with a comment reading \"a fourteenth declaring LayoutGeometry must come here and say so\" is false: a reviewer added a fourteenth pub struct declaring that kind, pointed at by nothing, and the whole 1439-test suite stayed green. Adding a variant is compiler-forced only where an EXISTING ARM MUST POINT AT THE NEW THING -- answering for it is forced, being reached is not. Same shape as improvise-5ubs (a cyclic-successor all() omitting a variant nothing points back to) and it appeared INSIDE the PR that found 5ubs. A count over a hand-written list asserts that the list has not shrunk, never that it is complete.').
doctrine(a_measurement_that_a_change_is_free_is_not_evidence_that_it_is_correct,'The sharpest lesson of the backlog campaign, in the author own words: \"I asked what does this cost, got nothing, and reported it as though it had also answered is this right.\" improvise-mcq4 narrowed an all-views frame observation to the active view, measured the narrowing at 12.41ms to 12.56ms -- free -- and kept it as \"the honest shape\". It cost a multi-client invalidation no test named: a CellData write pruning a row out of another client view became invisible, and gotcha 24 says that is strictly worse than doing nothing, because the diff then believes the bystander is current and it stays stale permanently. A performance measurement answers ONE question. Running it does not discharge the other, and a change that costs nothing is the easiest kind to keep for bad reasons, since there is no budget forcing anyone to justify it.').
doctrine(a_bug_can_hide_a_bug_and_the_tell_is_a_failing_precondition,'The reviewer P1 probe for improvise-mcq4 would not run: its precondition -- that the bystander grid really loses a row -- failed. Cause was a SECOND bug: project::grid_in builds a NON-ACTIVE view with GridLayout::new, which does not apply prune_empty, so the bystander projected grid never pruned. Measured: 3 rows via grid_in against 2 via with_frozen_records. The first bug was real and logically reachable and COMPLETELY UNOBSERVABLE because the second one masked it. Fixing grid_in is what made the P1 demonstrable; the P1 test is what found grid_in. So a precondition that will not hold is not a broken test to be relaxed -- it is a finding. Third instance of the GridLayout::new versus with_frozen_records trap (view_frame was the second): improvise-t6db.').
doctrine(it_broke_no_other_test_answers_observability_not_correctness,'The sibling of a_measurement_that_a_change_is_free_is_not_evidence_that_it_is_correct, one level up and in the same PR. Fixing project::grid_in to honour prune_empty for a non-active view broke exactly ONE test -- the probe written that same hour -- and that was offered as evidence nothing depended on the old behaviour. It is equally consistent with NOTHING HAVING TESTED IT, which is what it was. The honest answer came from asking WHY instead: the unpruned projection was internally consistent (seed and steady diff both went through grid_in, so they agreed with each other) and externally wrong (a browser client on a non-active view was shown phantom rows its own config prunes). The author was right, and right for a reason not yet established when they said it -- the difference between those two states is the entire value of the review loop.').
doctrine(a_property_test_over_two_configs_must_make_the_configs_actually_differ,'Writing the improvise-t6db host-view-independence property -- grid_in(app_on_A, \"B\") == grid_in(app_on_B, \"B\") -- the first version PASSED ON THE BUGGY TREE. It made the two views differ by turning prune_empty OFF in the second: plenty of differing config, and completely vacuous, because with nothing to prune the two code paths agree. The fixture must contain an ACTUALLY EMPTY ROW and leave BOTH views pruning. A test written the first way looks thorough and asserts nothing -- this repo characteristic defect arriving inside the very test meant to close it. Same trap named in improvise-iot6, whose fix needs the same non-vacuous fixture.').
doctrine(a_gate_that_can_exit_zero_answers_every_question_it_gates,'A flag ADDS a question; it never selects one away. A disclaimer line saying a check did not run still exits 0, and exit 0 is what CI branches on and what a human reads as clean -- so the rule is stated over the EXIT CODE rather than over the log: a tree with a long comment run must not pass ANY invocation that can exit 0. improvise-ijql: --check-evidence early-returned past the comment cap, and three agents hit it in one campaign. Note the orchestrator pointed at the improvise-rm4s precedent (where the fix was to make the success line NAME ITS SOURCE) and the author argued against it correctly: a provenance line qualifies an answer the run GAVE, a disclaimer qualifies one it DID NOT. --self-test and --list-doctests are excluded by decision, not exempted -- the first scans no tree, the second renders no verdict.').
doctrine(a_dispatch_bug_is_invisible_to_unit_self_tests,'When every unit in a file is correct and the defect is in main() argument dispatch, no fixture-level table can reach the wiring -- the guard must INVOKE THE PROGRAM, as a subprocess over a temporary tree. improvise-ijql was exactly that shape: every function in check-comment-runs.py was right, and the early return in main() was the bug. Its guard needs both arms: restore the early return and the clean-tree case must fail, AND force the cap to always report a violation, or an always-failing cap satisfies the first arm.').
doctrine(job_metadata_outlives_the_artifacts_it_describes,'An artifact-lifetime incident is still diagnosable after the artifacts 404: run, job, step and log APIs retain about 90 days where artifacts here retain 1. So a post-hoc artifact COUNT is not evidence about the incident, it is a reading of retention -- improvise-864a 0/1/0 table could not have been produced by expiry at all, since the newest-finalized expires LAST and expiry can only yield 0/1/1 or 0/0/1. Corollary that did the real work: widen the sample before theorising. The ticket framing of \"two of three artifacts\" was a SAMPLING ARTEFACT that made a two-day, sixteen-failure pattern look like a three-run concurrency race, which is the wrong shape entirely.').
doctrine(a_control_you_already_have_is_cheaper_than_the_hypothesis_you_were_about_to_test,'Diagnosing improvise-864a, the single observation that eliminated five competing explanations at once was a NEGATIVE one already sitting in a log: a run lookup reporting \"Found 2 artifact(s)\" -- its own harness-serve and wasm-bundles present, its own github-pages absent. Same run, same token, same call, same retention, so service flakiness, eventual consistency, retention, token scope and a v3/v4 backend mismatch all die together, since any of them would have taken the siblings too. The agent nearly did not fetch that log, having already established the timeline. Before testing the next hypothesis, ask what is already in hand that would discriminate.').
doctrine(an_injection_that_does_not_fail_names_an_untested_path,'check_the_checker is written as \"inject the fault and watch it fail\" -- and the case where it PASSES is the more informative result, because it is evidence about the TEST rather than about the code. On improvise-sihr, forcing ReturnToNormalUnlessMoved alone back to AppMode::Normal left the three-mode characterization test GREEN. That is how the untested path was found: an empty colon line takes ExecuteCommand is_empty branch and settles through ChangeMode, so the effect that settles every colon line which actually RUNS had no test at all. Two tests exist rather than one because of it. When an injection passes, do not move on -- ask which path the test never reached.').
doctrine(a_macro_effect_cannot_carry_a_derivation,'ChangeMode was a simple_effect!, which declares a one-field newtype writing one place -- so there was NOWHERE for a rule to live, and the exit mode had to be a constant in the keymap and in each commit command instead. That is why improvise-sihr could hide: every minibuffer mode returned to a hard-coded Normal and nothing owned the question. Gotcha 16 already warns the macro kind argument must be mandatory; this is a second cost of the same shape. Reaching for simple_effect! forecloses any answer that needs a derivation, so if an effect ever might have to RESOLVE what it writes rather than copy it, write it out.').
doctrine(a_ticket_that_pre_diagnoses_its_fix_as_a_protocol_change_deserves_checking,'improvise-9c9o was filed saying the honest fix needs the basis to follow active_view, which SwitchView being ViewOnly prevents -- i.e. a protocol change, not a one-liner. It was neither. The basis is App own bookkeeping and none of it is on the wire, so \"the basis follows active_view\" was achieved by moving WHERE the re-base happens -- into the loop, at the switch -- and SwitchView stayed ViewOnly. A pre-diagnosis in a ticket is a hypothesis wearing the authority of a decision, and the author of the ticket is usually the person with the least time to check it.').
doctrine(prose_that_calls_a_change_free_is_a_claim_and_is_checkable,'The sibling of prose_that_calls_something_permanent_forecloses_the_ticket, found one paragraph below it in the same section. gp.md section 11 said improvise-zv8t cost nothing -- \"no document that fitted before stops fitting\" -- reasoning that a tree splitting on a numeric category was refused as an artefact anyway. FALSE: the refusal is conditioned on used_features, so a fit that never split on the column SUCCEEDED, and the wide columns are exactly the ones a fit has least reason to split on. Measured with a throwaway ignore-probe on both sides: 100 records, a 100-item numeric Year beside a Cost that predicts Score alone -- pre-fix the fit lands on IF(Cost < 48.015..) with Year never touched, post-fix the preflight refuses at 101 features. The suite was green on either reading. A cost claim is as checkable as a permanence claim and is checked the same way: construct the document the claim says does not exist.').
doctrine(forcing_by_name_while_budgeting_by_index_defeats_the_budget,'improvise-zv8t made fit_measure force header[..category_columns] to Forced::Categorical keyed by NAME, while encoded_width decides category-ness by INDEX. A document may legally name a regular category and a raw measure the same thing (a_category_and_a_measure_of_the_same_name_are_refused ships that fixture), so the MEASURE column was forced categorical by name match while the preflight still counted it numeric by index: measured 41 against 80 at 40 records. no_ambiguous_column_names ran AFTER preprocess, so the expansion the preflight exists to prevent was allocated and only then refused -- guard_the_cost_not_the_result defeated by an inconsistency the fix itself introduced, NOT the bounded approximation a_budget_check_is_advisory_or_exact_and_must_say_which licenses. Repair is a data dependency, not a moved line: as_categories runs the check and returns Result<Overrides>, so the Overrides preprocess needs cannot exist until the header is accepted. Guard is the SIGNATURE -- reordering the two statements is error[E0425], verified. Deliberate deletion of the check from as_categories is still possible and is stated rather than implied.').
doctrine(a_mitigation_that_names_the_wrong_file_is_not_a_mitigation,'PR #91 deferred a wire-level gap on the ground that \"keys.rs emits no ChangeMode at all\". True, and irrelevant: keys.rs holds only BrowserKey, and the LIVE browser key resolver is improvise_protocol::resolve, re-exported as improvise_wasm_client::resolve -- which emits Command::ChangeMode(AppMode::Normal) at five sites. So the browser front end kept the very bug the PR fixed for the TUI, and the suite was green over a divergence the PR itself created. The sentence was in context/, which design-principles section 6 names as exactly the place a reason must not be wrong, because nothing can find it later. Before writing \"X does not do Y\" as the reason a gap may be deferred, GREP FOR Y rather than for X -- the claim that matters is about the behaviour, and the file you happen to have open is not the only place it can live. The orchestrator copied the same false clause into the bead it filed.').
doctrine(an_example_that_works_for_another_reason_defends_nothing,'improvise-sihr resolved AppMode::Normal against a pre-batch layout (gotcha 23) and defended the staleness in prose with the case of toggle-records-mode, which does work in both directions. It works for a DIFFERENT REASON: ToggleRecordsMode emits change_mode(RecordsNormal) explicitly on entry, and on exit ViewBack restores the mode SwitchView pushed, so normal_mode() answer is overwritten in both directions and the staleness is neutralised there rather than exercised. The case that would actually test it -- a batch that CROSSES records-ness and then sets Normal -- was untested, and regressed: from a records grid, \":toggle-records-mode . enter-mode normal\" landed in RecordsNormal where it was right before. An example chosen because it passes is not evidence; ask WHY it passes.').
doctrine(a_list_checked_for_length_reads_like_a_list_checked_for_completeness,'assert_eq!(cases.len(), N) catches a case being DELETED from a list and never one being ABSENT from it, and the two are written identically. Four instances here: AppMode::all(), Rejection::all(), and twice inside one PR. A cyclic total after() does NOT close it -- answering after() FOR a new variant is compiler-forced, pointing an existing arm AT it is not, so a variant nothing names is never walked (all().len() came back 17 against 18, suite green). Closing it needs an enumeration INDEPENDENT of the derivation, and stable Rust has none for an enum carrying data: variant_count is nightly, and strum EnumIter cannot construct a variant holding a MinibufferConfig. SERDE CAN -- the Deserialize derive builds the complete variant-name list from the enum definition and publishes it in its unknown-variant message. improvise-5ubs reads it back in crates/improvise-protocol/src/variants.rs (cfg(test)) and diffs it against what the cycle reached. The dependency on serde message format is made LOUD rather than silent: it panics naming the message if the split fails, and asserts the parsed list has more than one name, so a format change cannot degrade into a vacuous pass.').
doctrine(a_consolidations_frozen_behaviour_is_a_date_not_a_decision,'A consolidation must PRESERVE the behaviour it moves, or its agreement test cannot tell \"both ends agree\" from \"both ends changed together\" -- so the moved rule is frozen FOR THE DURATION OF THE MOVE. Afterwards a single home reads as decided, and nothing in the tree says otherwise. improvise-8qlr froze name-only group coalescing because both prior copies did it; improvise-h0iv later changed it once for both ends, and found the test still pinning the old rule justifying itself with \"because that is what src/ui/grid.rs uses\" -- a clause 8qlr had already made false, since grid.rs calls group_run_heads now. When a consolidation lands, say in context/ WHICH of its behaviours were preserved for evidence rather than on merit; otherwise the next reader inherits a frozen date as a decision.').
doctrine(a_claim_in_context_must_name_the_file_it_was_checked_against,'PR #91 wrote \"keys.rs emits no ChangeMode\" into context/code/command.md to argue a divergence was latent. True and irrelevant: keys.rs holds only BrowserKey, and the live resolver is improvise_protocol::resolve, which emits it five times (improvise-e5pf). Nothing checks a claim in context/, which design-principles section 6 names as exactly why a reason must not be wrong there. So a NEGATIVE claim grepped from one file must name that file, and a reviewer must re-derive it from the CALL GRAPH rather than from the name. ROOT CAUSE, and it was the orchestrator: repo-map row \"Change what a browser resolves without the host\" pointed at wasm-client/src/keys.rs, so the author consulted the map, opened the named file and grepped it honestly. A stale pointer in the map does not merely fail to help -- it manufactures confident false claims, because the reader has done the work the map told them to do.').
doctrine(a_per_element_resolution_cannot_fix_a_per_batch_property,'improvise-sihr round 1 resolved AppMode::Normal inside each effect that sets it, and got the CROSSING case wrong -- a batch that leaves records and then sets Normal landed in RecordsNormal. Not an oversight: gotcha 23 means any SET-TIME read sees the batch OPENING layout, so no per-effect resolution can answer a question about where the batch ENDS. Round 2 moved it to one call, settle_normal_mode after rebuild_layout in apply_effects, and DELETED MORE THAN IT ADDED: three writers became one, ChangeMode went back to the simple_effect! macro, and the uncovered-edit finding dissolved rather than being patched. Ask \"where is the one place this can be answered\" BEFORE \"which writers must I teach\" -- and note the tell that the first answer was wrong: the author was writing a DEFENCE of the staleness rather than a test of it.').
doctrine(a_guard_seeded_on_agreement_cannot_detect_drift,'lockstep.rs records coverage sets host.view_state.mode = AppMode::records_editing() BY ASSIGNMENT and then seeds the client with client.view = app.view_state.clone(), so it begins from a state where the mode and the layout already AGREE. Every divergence it exists to catch requires them to DISAGREE, so the whole class is invisible to it by construction (improvise-e5pf). Same vacuity shape as gotcha 36 bystander already sitting on the relayed value, and as the improvise-t6db property whose two views differed only by having nothing to prune. A lockstep or agreement test must DRIVE THE INPUTS that produce the state, never hand-place the state -- hand-placing is what makes the fixture agree, and agreement is the thing under test.').
doctrine(a_substitution_is_a_defect_at_every_site_that_makes_it,'improvise-e5pf was filed as one command folded without a resolution. A sweep found the real fault is ONE SUBSTITUTION AT THREE DECISION POINTS: wherever the host asks layout.is_records_mode(), the client asks state.view.mode.is_records(). The wire command is only how the client REACHES the bad state; the substitution is why the bad state does damage, and it decides whether enriching the projection is even sufficient. Worse, the first site inverts its own failure mode: commit() is written to DECLINE a records commit it cannot compose, and with the mode wrong it does not decline -- it composes a typed_cell_command and a Basis claim against a records grid whose columns are [coords, +cat, measures, +], which is a WRONG WRITE rather than the intended black hole. When a ticket names a symptom, grep for the PROXY the symptom rests on; a proxy used once is used everywhere the same question is asked.').
doctrine(comparing_a_function_against_itself_tests_nothing,'improvise-jmjz fused three grid passes into one and guarded the width half with \`one_row.col_widths == compute_col_widths(model, &app.layout, false, 0)\`. But compute_col_widths IS grid_body with an empty window -- the same function twice -- so windowing the widths degrades BOTH SIDES IDENTICALLY (both collapse to the MIN_COL_WIDTH clamp) and the assertion cannot fail. Injecting \`if drawn { widen(..) }\` left 1476 tests passing. The fixture hid it a second way: the column header \"Revenue\" is wider than the value \"300021\", so the width assertion passed on the header alone. A check whose two sides share an implementation is a tautology wearing the shape of a comparison -- and a consolidation is exactly when one gets written, because the second implementation has just been deleted. The discriminating fixture puts the widest DATA value outside the window and makes it exceed both the header and the total (values 1, 888888888, -888888888: the total cancels to 1.0 so it cannot supply the answer).').
doctrine(a_negative_result_is_only_as_good_as_the_setup_that_produced_it,'PR #92 reported that a none_cats hazard was \"argued and unobserved at this shape\" after widening a fixture to probe it. Printing the actual axes showed Product on COLUMN in every view -- add_category defaults it there and nothing moved it -- so none_cats was {_Index, _Dim} identically everywhere and THE STATED INPUT TO THE HAZARD WAS ABSENT. The path was unprobed, reported as a negative result. This is an_injection_that_does_not_fail_names_an_untested_path applied to a SETUP rather than to an injection, and it is worse, because a recorded negative stops the next person looking. Before writing \"probed and not reproduced\", ASSERT THE PRECONDITION IN THE TEST -- print or assert the condition the hazard needs, so a fixture that drifted out of the shape fails loudly instead of reporting absence.').
doctrine(the_elsewhere_path_is_only_as_correct_as_the_inputs_it_can_rebuild,'project::grid_in projects a view the host is NOT on. Twice now it has been wrong because it silently inherited an input computed for the host view. improvise-t6db was the first: it built with GridLayout::new, which skips prune_empty, so the other client grid never pruned -- one line to fix. improvise-ac2z is the second and is NOT local: App::rebuild_layout fills Model::formula_cache under the HOST view none_cats, and grid_in builds another view layout against that stale cache, so Total cells are keyed without the coordinate the other view puts on an axis, the lookup finds nothing, and prune_empty drops the formula columns entirely. ABSENT COLUMNS, not wrong numbers. The fix is not local because grid_in takes &App while recompute_formulas needs &mut Model. The general rule: when a function serves a context other than the one its inputs were computed for, enumerate those inputs and ask of EACH whether it was derived for the caller or for the subject -- a green suite says nothing, because every fixture where host and subject agree is vacuous for this class.').
doctrine(a_conflicting_pr_runs_no_ci_because_it_has_no_merge_ref,'A pull_request workflow runs against refs/pull/N/merge, and GitHub cannot build that ref for a CONFLICTING pull request -- so NO RUN IS EVER CREATED and the PR simply shows zero checks. That is indistinguishable from a dropped webhook if you only look at the runs list. PR #92 sat with zero checks across two pushes and a close/reopen; the cause was that PR #91 had merged and both had appended to the same four context/ files. DIAGNOSIS ORDER: before theorising about event delivery, ask \`gh pr view N --json mergeable\` -- one call, and it answers first. The orchestrator ran a genuinely good control (push to another PR, watch a run appear in five seconds) which correctly proved delivery works repo-wide, and then drew the WRONG conclusion from a sound experiment, because the hypothesis space had two entries and the truth was a third. A control discriminates only between the hypotheses you listed.').
doctrine(no_checks_reported_is_a_merge_state_symptom,'A pull_request-triggered workflow builds refs/pull/N/merge, and a CONFLICTING pull request HAS NO MERGE REF, so no run is ever created -- \"no checks reported\" then looks IDENTICAL to a dropped webhook. Measured on PR #92, 2026-09-21: 34 minutes of polling actions/runs?head_sha=, a close/reopen and an empty commit all produced zero runs; one git merge main produced a run in seconds. ASK gh pr view --json mergeable,mergeStateStatus FIRST -- one call answers what no amount of polling the runs API can distinguish. Both the agent and the orchestrator hypothesised a dropped event and a stalled delivery; NEITHER LISTED THE THIRD CASE, and the orchestrator control -- pushing to another PR and watching a run appear in five seconds -- is consistent with all three. A control discriminates only between the hypotheses you listed.').
doctrine(grep_over_git_diff_is_silently_empty_under_an_external_differ,'This repo configures diff.external = difftastic, which prints a SIDE-BY-SIDE format carrying no unified-diff markers. So \`git diff | grep \"^-\"\` matches nothing REGARDLESS OF CONTENT, and an agent verifying \"nothing was dropped in the merge\" read three consecutive empty outputs as three clean bills. Verified by the orchestrator: on a file with one real deletion, git diff | grep -c \"^-\" gives 0 and git diff --no-ext-diff | grep -c \"^-\" gives 1. Use --no-ext-diff in any programmatic read of a diff, or --numstat / --name-only, which are unaffected. The general shape, and it is the sharpest form of check_the_checker: A CHECK THAT RETURNS EMPTY FOR THE WRONG REASON IS INDISTINGUISHABLE FROM ONE THAT PASSES -- and an empty result is the one outcome nobody investigates.').
doctrine(read_the_reviews_before_merging_and_authorship_will_not_identify_them,'Twenty PRs were merged in one campaign without reading their REVIEWS -- CI green plus a dispatched adversarial agent felt like the whole gate, and it was not. Two ChatGPT Codex reviews were found afterwards on merged PRs; both were correct and one was a live P2 (improvise-x1ns). Three things make a Codex review easy to miss. It arrives as a REVIEW, not a comment, so scanning the comment thread does not show it. It posts under the repository OWNER account -- the same account our own agents post under -- so AUTHORSHIP DOES NOT DISTINGUISH IT; the tell is the trailing line \"Automated review by ChatGPT Codex\", where ours OPEN with \"Posted by Claude\". And it may target an EARLIER head, so a finding can be already fixed or still live and you must verify which rather than assume from the date. Reviews arrive as the owner quota allows, so absence is not evidence of approval. Answer a review on the thread even after the PR has merged: that is where the next reader looks.').
doctrine(bounded_by_a_later_check_does_not_bound_the_cost,'improvise-x1ns: encoded_width is a PREFLIGHT whose whole purpose is to refuse an expansion BEFORE allocating it, and its categorical branch under-counted by the missing level that build_categorical actually emits. The wave own review had seen the shape and reasoned it away as \"bounded by the exact check\". Codex rebuttal is the keeper: THE EXACT CHECK RUNS AFTER THE ALLOCATION, so it bounds the error MESSAGE, not the cost. When a guard exists to prevent work rather than to report a wrong answer, a downstream correctness check is not a backstop for it -- the thing you were protecting has already happened. Second instance on this path: improvise-zv8t own round found forcing by NAME while budgeting by INDEX, repaired as a data dependency so the ordering became a compile error. Both are the same shape -- two halves of one decision disagreeing about what a column costs.').
doctrine(inject_the_rival_fix_not_only_the_bug,'check_the_checker says inject the fault and watch the check fail -- which proves the test SEES THE BUG. It does not prove the test encodes the RULE. On improvise-711n two implementations both repair \",.2f\": take the leading digit run after the last dot, or trim trailing non-digits. They differ on exactly one shape, \".2f3\", and without that case the assertion is satisfied by either. So the discriminating injection is not the original bug but the OTHER PLAUSIBLE FIX. Ask, for every repair: what else would make this test pass, and is that thing also correct? Where a rival exists, the fixture must contain the input that separates them or the test pins a behaviour rather than a rule.').
doctrine(a_preflight_must_mirror_every_branch_the_real_path_can_take,'encoded_width is a cost preflight over preprocess, which dispatches Numeric -> Date -> Categorical. It mirrored two of the three. improvise-x1ns was the categorical arm under-counting the (missing) one-hot; ChatGPT Codex then found the DATE arm missing entirely -- a date column takes the categorical fallback because \"2024-01-15\" does not parse as f64, so a column holding one distinct date promises 1 where build_date materialises 5, or 7 with a time. Two findings, one shape, one inference branch apart. A preflight is a SECOND IMPLEMENTATION of the thing it guards, so it inherits every branch of it, and the branch nobody mirrored is the one nobody was thinking about. Note the second finding contradicted an invariant proposed in the same hand-back that fixed the first: the counter-example was already in the tree.').
doctrine(an_invariant_generalised_from_a_fix_generalises_the_fix,'Having repaired the categorical under-count, the Wave M agent proposed a never-under-counts invariant -- WHILE THE TREE ALREADY HELD A COUNTER-EXAMPLE, the missing Date arm, which ChatGPT Codex then found. An invariant written immediately after a fix tends to state what the fix established rather than what the code guarantees, because the evidence in front of you is the defect you just closed. The question that would have caught it is about the OTHER function, not about the bug: \"what else does the thing I approximate do that I have not mirrored?\" The agent retracted it unprompted, before it was ever recorded, and replaced it with the per-arm form now held as encoded_width_mirrors_every_preprocess_branch, which is the right outcome and the reason to record this.').
doctrine(an_advisory_bound_over_another_function_is_a_duplicated_derivation,'encoded_width exists to approximate what preprocess will allocate, which makes it a SECOND IMPLEMENTATION in the improvise-edp sense -- and both of its bugs were invisible to a green suite for the same reason: NOTHING COMPARED THE TWO AT ALL. A width asserted against a constant passes at every stage while the approximation drifts. The only honest test of an advisory bound is to run the thing it approximates and compare against what that actually produced (here data.feature_names.len()), never against a number computed a second way -- which would be comparing_a_function_against_itself_tests_nothing wearing a different hat.').
doctrine(when_review_finds_a_check_weaker_than_claimed_strengthen_the_check,'improvise-26dm round 1 found its own defect -- the right-hand side of a completeness diff was a second HAND-WRITTEN list, not the derived enumeration the prose claimed -- and shipped an honest correction of the prose. ChatGPT Codex asked for the FIX instead, and was right: the disclosure was accurate and still the weaker artefact, because the fix was four lines of plumbing. Correcting prose is the FALLBACK, not the deliverable. The repaired chain is derive -> every_model_command() -> model_effects -> case list, every link checked, bounded by variants::declared::<ModelCommand>(). Note the staged failure is better evidence than a single one: Codex own injection now fails the two checks ONE AFTER THE OTHER, and that ordering demonstrates the chain has two links rather than one test doing two jobs badly.').
doctrine(cfg_test_and_pub_crate_are_different_walls,'A visibility plan written from a grep of the item line will miss the gate on the line ABOVE it. improvise_protocol::variants reads pub(crate) mod variants, so the follow-up -- filed by the agent that wrote it, and repeated by the orchestrator authorising \"one token\" -- said pub(crate) to pub. The attribute above is #[cfg(test)], and declared() needs serde_json, which that crate carries only as a DEV-dependency, so the one-token change could not compile. The shape that works is an optional serde_json behind a test-support feature, #[cfg(any(test, feature = \"test-support\"))] pub mod variants, and a dev-dependency enabling it from the consumer.').
doctrine(cargo_tree_shows_dev_edges_by_default,'Asking whether a feature leaked a dependency into a shipped build, a plain cargo tree -p improvise-protocol --target wasm32-unknown-unknown reports serde_json PRESENT -- because the default output includes dev edges. --edges normal is what answers the question, and it reports 0. Verified both ways by the orchestrator. Same class as grep_over_git_diff_is_silently_empty_under_an_external_differ: a command that looks like it answers the question, answering a neighbouring one, in the direction that reads as a finding rather than as a pass. A feature that adds a dependency to a wasm bundle crate must be checked with --edges normal, and per-crate, because the CI wasm job checks each crate separately and feature unification cannot reach across that.').
doctrine(an_unreachability_claim_is_only_as_wide_as_the_routes_enumerated,'PR #98 predicted the composition-time mode drift itself and declared it unreachable BECAUSE the toggle route (R) is a stranding decline in the browser. True of that route. A VIEW SWITCH reached the same drift, because records-ness is auto-detected from the axes, so a switch changes it with nothing in the batch saying \"records\" -- found by ChatGPT Codex. The ENUMERATION of routes is the part nothing checks, so an unreachability argument is evidence about the routes you thought of and about nothing else. Same shape as a_mitigation_that_names_the_wrong_file_is_not_a_mitigation. The remedy is to close the CLASS by observation rather than to patch the route.').
doctrine(a_precondition_derived_from_its_own_expectation_guards_nothing,'A loop test whose precondition is computed from the same tuple element as the assertion cannot be relaxed one iteration at a time: relaxing the assertion relaxes the guard too, and the test then fails for the wrong reason -- which reads exactly like the bug. Assert the two as SEPARATE facts. Corollary for check_the_checker over a loop: an injection that stops the loop at iteration 1 leaves every later iteration untested, so each direction must be reached deliberately. Found by the PR #98 author in its own bidirectional test while proving the second direction.').
doctrine(a_pipe_discards_the_exit_code_you_meant_to_read,'FOURTH occurrence in one campaign, and the last one was in a tool written TO BE the reliable answer. scripts/pr-watch once read tr exit status through a pipe. An orchestrator probe reported EXIT=0 from a checker that had exited 1, because the pipeline ended in tail. Another read PIPESTATUS in zsh, where the array is lowercase pipestatus. And scripts/pr-reviews --wait correctly exited 1 on a timeout while the invocation \`... | tail -20; echo\` reported 0 -- so a review window that had EXPIRED read as a review that had ARRIVED, which is the dangerous direction. In bash and zsh the exit status of a pipeline is the LAST command, so any \`cmd | head\`, \`| tail\`, \`| grep\` discards it. Redirect to a file and read the file, or check PIPESTATUS[0] in bash / pipestatus[1] in zsh -- and note the two shells differ, which is its own trap. A gate whose status you sampled through a pipe has told you about the pager.').
doctrine(editing_a_shell_script_corrupts_the_instances_already_running_it,'bash reads a script INCREMENTALLY rather than loading it whole, so editing a file while an instance is executing it shifts the byte offsets underneath that instance. Two background pr-reviews watchers, started ten minutes earlier and sleeping in their poll loops, both died with \"syntax error near unexpected token do\" the moment the orchestrator appended a paragraph to the usage text -- and the script itself was syntactically fine, which bash -n confirmed. The failure is in the RUNNING instance, not the file, so it is invisible to every check you would think to run afterwards. Before editing a script, ask what is still executing it; a long-poll watcher is exactly the shape that will be. Worse in this case than a crash: the watcher exited having printed only the EARLIER review, so the wait it was supposed to perform silently did not happen.').
doctrine(a_worktree_agent_merges_the_orchestrator_uncommitted_local_main,'Agents in git worktrees share the repository local refs, so \`git merge main\` in an agent worktree takes the ORCHESTRATOR LOCAL main -- including commits never pushed to any remote. That is how an unlisted context/code/pr-review.md, committed straight to local main by the orchestrator, reached PR #98 branch and turned its build job red on the doc-site manifest gate (improvise-res9 working exactly as designed). The agent diagnosed it as \"main has been red since PR #96\", and BOTH halves were wrong: CI on #96 merge was green, and the doc was not in main when #96 agent merged at ea206bf -- it entered between, so only the LATER branch saw it. The orchestrator local main is an unreviewed publishing channel into every running agent, and a commit there is subject to the same gates as a PR without getting them. Either run the gates before committing to local main, or do not commit there at all.').
doctrine(a_manifest_gate_fires_at_merge_time_not_at_authoring_time,'scripts/build-doc-site.mjs refuses a context/**/*.md that is neither published nor a named omission -- a good gate (improvise-res9). But it runs in the \`build\` CI job, so a doc added in one branch makes every LATER branch red while the author who added it never sees a failure. The cost lands on whoever merges next, who then diagnoses somebody else change. Worth moving to the \`test\` job, which is fast and needs no wasm build. Generalises: a gate over a REPOSITORY-WIDE invariant should run wherever the violation is authored, not only where the tree is assembled.').
doctrine(a_derivation_protects_only_what_it_derives,'improvise help pages are DERIVED from the keymap -- HelpLine::Keys rows call keys_for(mode, command, args) and a row whose command resolves to nothing is DROPPED, guarded by every_help_page_key_row_names_keys_that_exist. Excellent architecture, and it protected exactly the rows it covers. The HelpLine::Literal rows -- the ex-commands and the prefix sequences -- escaped, and that is precisely where the audit found the worst content bug: every documented spelling of :formula fails, on the quick-start page, for the headline feature, because the parser splits on whitespace and nothing says to quote. When a derivation is introduced, ENUMERATE WHAT IT DOES NOT COVER AND SAY SO, because the covered part now looks trustworthy and the uncovered part looks the same.').
doctrine(a_surface_that_paints_another_surface_hints_advertises_what_it_cannot_do,'The browser paints the HOST hint line and the HOST help pages while having a smaller capability set. Four keys the browser itself advertises are declined() out loud; ten more resolve Upstream to Host::handle answering an explicit empty vec, so they do nothing and say nothing -- including help page 2 entire Search section and page 3 \"Enter - Edit current cell (same as i)\", where i works and its documented synonym is inert. The loud tier reads as a broken app; the silent tier reads as a broken app AND teaches the user something false. Sharing a derivation between surfaces is right (doctrine about not reimplementing behaviour), but a shared VOCABULARY still owes a per-surface CAPABILITY FILTER, or every surface advertises the union.').
doctrine(the_status_bar_and_an_overlay_are_two_writers_of_one_fact,'draw() stacks the tile bar and the import-wizard overlay ABOVE the status bar, so both are on screen together and each draws its own key hints. They disagree in notation and in content, and the wizard status tail is WRONG ON FOUR OF EIGHT STEPS -- on Done it says Esc cancels where Esc goes back, an outright contradiction the user can act on. The overlay hints are correct because they are local and per-step; the status tail is a second table keyed only by AppMode, which cannot see the step. Two writers of one fact is improvise-edp disease in the UI layer. The cheap repair is to stop drawing the second one where an overlay draws its own, not to maintain both.').
doctrine(establish_a_platform_fact_by_running_it,'When a design turns on what a platform API does, measure it in the target engines before designing against it -- a recalled spec and a plausible inference are both worth less than one probe, and the cheapest instrument is usually already in the tree. Measured 2026-09-21: the SharedWorker/OPFS question was settled in minutes by booting the EXISTING harness/opfs-contract bundle in a shared worker, and it returned three results no amount of reading would have produced with confidence -- the method is absent rather than failing, WebKit fails one step earlier for an unrelated reason, and Firefox ALONE permits a nested dedicated worker that gets a working handle (Chromium and WebKit throw \"Worker is not defined\"). That last one is the point: the single shape that would have cost nothing works in exactly one engine, which is indistinguishable from \"it works\" to anyone reasoning from one browser or from the spec. Corollary: report per engine, and never let a per-engine gap hide behind the word \"browsers\".').
doctrine(a_status_line_composed_beside_a_mutation_lies_when_the_mutation_no_ops,'An optimistic status effect emitted ALONGSIDE a fallible mutation reports success the mutation never achieved, and because status is ViewOnly the client folds it locally and no host ever contradicts it. Gotcha 20 already states the cure for one case -- CommitBuffer emits the fallible effect LAST so its failure message wins -- and improvise-g3no found the rule broken in BOTH front ends at once, in the same shape: delete_at_cat_cursor and DeleteCategoryAtCursor each compose [RemoveItem, SetStatus(\"Deleted item ..\")] with the fallible half FIRST. Model::remove_item returns unit, so nothing downstream could have known better. Two rules generalise. A mutation whose failure is reportable must RETURN whether it acted -- a unit-returning mutation makes the lie unavoidable rather than accidental. And the front ends are the wrong place to notice: the same defect appeared independently in a Cmd and in a resolver, which is what a shared derivation exists to prevent, so the fix belongs where both compose from. No test failed, because every test asserted the status string that the code composes unconditionally.').
doctrine(a_merge_prints_pop_whether_or_not_it_stashed,'On a conflicted merge with merge.autoStash configured, git prints \"When finished, apply stashed changes with git stash pop\" EVEN WHEN NO AUTOSTASH OF YOURS WAS CREATED. Observed three times in one session (2026-09-21 wave q/r/s/t integration), TWICE with an empty autostash, where stash@{0} was another session WIP on review. This repo stash stack is shared across nine worktrees, so the entry git invites you to pop is routinely someone else work. Check git stash list before believing the hint; identify your own entry BY SHA and use \`git stash apply <sha>\`, never pop. This is the same mechanism that corrupted .beads/issues.jsonl earlier the same day, arriving as advice from git rather than as a choice anyone made -- which is what makes it dangerous.').
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.').
work_done('improvise-k8h',refactor,'TextEntry::new/with_parent now take the AppMode and read the buffer name off its MinibufferConfig, so the keymap no longer spells a buffer name. Added two guard tests in keymap.rs pinning mode<->keymap agreement and the completeness of the text-entry mode list.').
work_done('improvise-4pg',refactor,'Extracted PanelKeymap builder for the three panel modes, following the TextEntry precedent. Panel::ALL/name()/key() drive the toggle keys for Normal and all three panels. Verified by diffing all 4065 (mode,key,modifier) lookups before and after: identical.').
work_done('improvise-6mq',refactor,'persistence save/load routed through a Storage trait; save/load kept as PhysicalFs wrappers so all 83 persistence tests pass unchanged; MemoryFs gives a filesystem-free test fixture.').
work_done('improvise-mae',refactor,'all 58 effects classified as ViewOnly or Model(CellData|LayoutGeometry|WholeWorkbook); kind() has no default so the compiler enforces completeness; three tests verify declarations empirically by applying effects and fingerprinting shared state.').
work_done('improvise-jc8',refactor,'ViewState no longer holds Arc<Keymap>; four characterization tests written before the change pinned gg/ZZ/prefix-consumption/records-inheritance, plus two new guards for flatness and parent resolution.').
work_done('improvise-ywd',audit,'added wasm32-unknown-unknown to the flake toolchain, which was the real blocker; all four bundle crates then checked clean with zero source changes. Swapped Instant for web_time and added the CI wasm job.').
work_done('improvise-fuj',refactor,'App gained a storage backend; Save/SaveAs/ExportCsv/LoadModel/StartImportWizard and CSV import all route through it. Test drives save, export, load and wizard-seed over MemoryFs with no disk.').
work_done('backlog-groom-2026-08-30',groom,'closed improvise-ont as obsolete; raised 7 priority inversions to P2; added 2 verified dependency edges out of 21 candidates; corrected stale refs on ai7/ct5/gdl and stale assumptions on jb3/git.').
work_done('improvise-h0b',refactor,'wizard and drill_state moved from ViewState to App. Demonstrated first with a ViewState: Send static assertion that failed to compile, naming Rc<Vec<RecordRow>> exactly. Second test proves staged drill edits are unreachable from ViewState Debug output.').
work_done('improvise-a2x',refactor,'serde added to improvise-command; Serialize+Deserialize derived on AppMode, MinibufferConfig, MinibufColor, ViewFrame, KeyCode, KeyModifiers and ViewState. buffer_key and TextEntry::buffer became owned Strings. Roundtrip test populates one field per family -- a default-value roundtrip would pass while proving nothing.').
work_done('improvise-8uz',refactor,'View lost selected/row_offset/col_offset; ViewState gained cursors: HashMap<String,ViewCursor>, reached via App::cursor/cursor_mut/reset_cursor. Cursor resets moved from View::transpose_axes and View::cycle_axis into the TransposeAxes and CycleAxis effects. Workbook::normalize_view_state deleted as vacuous; the two effects that swap the whole document now clear the cursor map instead.').
work_done('improvise-rml',refactor,'Axis gained next/color/plain_label/glyph/short_label/initial; Display is now plain_label+glyph. Four duplicate match sites replaced: tile_bar, category_panel, view_panel, cmd/tile.rs. View::cycle_axis now calls Axis::next. Characterization test pins all four label vocabularies verbatim.').
work_done('improvise-dz8',feature,'Axis::Filter added with filters_data/1 and is_user_assignable/1; View gained filtering_cats and tileable_cats. DrillIntoCell fixes coordinates on Filter instead of Page. GridLayout and add-record-row switched to filtering_cats; the three tile-select sites to tileable_cats; page cycling deliberately left on categories_on(Axis::Page).').
work_done('improvise-i34',feature,'Storage gained list/delete, implemented for PhysicalFs and MemoryFs with a shared storage_contract test both satisfy. OpfsFs added behind cfg(target_arch=wasm32) using web-sys sync access handles, with open/attach/refresh/commit_deletes as the async seam. Compile- and clippy-clean for wasm32.').
work_done('improvise-cqi',feature,'crates/improvise-protocol created. ViewState, ViewCursor, ViewFrame, AppMode, Panel, MinibufferConfig, MinibufColor, HELP_PAGE_COUNT and the key types moved there; improvise-command re-exports them so no caller path changed. Command + reduce_view + RenderCache/CellDisplay/Snapshot added with 15 tests. Added to the CI wasm job.').
work_done('improvise-sef',maintenance,'kb.pl audited and repaired (a406b52): may_depend_on/2 and before_fixing/2 were unsound, and sixteen lines of captured MCP runtime predicates were removed. kb_report/0 and kb_consistent/0 added (3a58d3c) over four error classes -- domain integrity, layering, rule soundness, functional keys -- and wired into CI; every class verified by fault injection. Backlog-grooming method recorded (3697171).').
work_done('backlog-groom-2026-09-19',groom,'ran the five 2026-08-30 checks over 62 open/deferred issues. Cycles: none. Priority inversion: one (g0f P3 blocking cqq P2), raised. Unrecorded deps: 11 prose mentions, 1 real (lae blocks git, by the ticket own words), the rest analogues or siblings. Stale citations: 2 broken paths (nr5 doubled crate prefix, three gp-tree tickets pointing at examples/fit_csv.rs instead of crates/gp-tree/examples/), 9 line-number drifts corrected to symbol references. Dissolved premises: ede and toc closed, ete demoted to P4, 2 of 5 items struck from 7yx.').
work_done('improvise-0yq8',refactor,'ClientState -> improvise-protocol/src/client.rs and keys.rs::{resolve,step,KeyOutcome} -> improvise-protocol/src/resolve.rs, with socket.rs::{typed,typed_cell_command} following step. BrowserKey stays in the wasm client: it is the browser half of the two things a front end owns. render_model became a free function, ClientState being foreign. 1311 tests before and after -- no behaviour change.').
work_done('improvise-e24v',feature,'Fifteen command names resolve client-side: cycle-panel-focus, the category panel five, the view panel three, both tile-axis names, both help-page steps, commit-buffer in three kinds, clear-cell and paste. New lockstep harness applies what the client composed to a SECOND authoritative host and compares documents as .improv text (Debug prints the symbol table HashMaps, so it differs per process). Four injected faults, each failing a named test; one needed the fixture widened first. Found and fixed a wire/effect divergence in Command::HelpPage.').
work_done('improvise-x7sw',refactor,'Closed with improvise-ktv1 and improvise-k099 as one question. improvise-protocol/src/help.rs is the one home for a UI string; AppMode::label/hint_text and Keymap::binding_hints deleted, along with every_hint_key_resolves_in_its_mode and its 60-line tokenizer. The TUI help overlay kept its ratatui styles and lost ~310 lines of content; the browser gained one (RenderModel::help, empty means closed), asserted by a new Chromium test in harness/tests/shell.spec.js proved discriminating by two injected faults.').
work_done('improvise-vz54',bug_fix,'node --test with an unmatched glob ran zero tests and exited 0 on both CI node steps. scripts/node-test.sh now owns the expansion and refuses an empty match; it also refuses a matched file that declares no test, checked LEXICALLY because node reports such a file as one passing test named after the file, so its own count reads as the FILE count. scripts/node-test-test.sh proves each refusal by injection and runs in the js job.').
work_done('improvise-5ray',bug_fix,'--check-evidence collected only ### headings and resolved names against Rust fn names, so every browser entry was invisible. Now collects ###/#### alike, classifies by shape, and resolves JS names out of harness/tests and web/tests. It immediately found a real orphan: 36ade35 deleted the Playwright test that asserted the browser draws help and left its heading behind (improvise-v5pc).').
work_done('improvise-lyyc',bug_fix,'--check-evidence gained its second direction: a test whose doc comment names context/test-evidence must have an entry there. It immediately found one, an_entry_that_holds_no_document_is_seeded_over, which had cited the file with no entry since it was written. Ten evidence cases were added to the checker\'s own --self-test, which had none.').
work_done('improvise-x4wv',bug_fix,'web/assemble.sh\'s stamped guard exited on the FIRST missing cache-bust stamp, so breaking two substitutions named one and cost a wasm-pack rebuild to find the next. stamped now records and a gate after the four calls fails the build. Both the fix and the gate\'s own absence were proved by injection; the comment says the gate is the whole guard.').
work_done('improvise-2f97',feature,'kb_error(dangling_citation/3): a name cited inside another clause\'s text must resolve as a doctrine/2, invariant/2 or decision/3. The token rule is narrow on purpose -- only a name-shaped token (lowercase, digits, underscores, at least one underscore) directly inside doctrine(, invariant( or decision( -- so prose cannot false-positive. Retiring doctrine(an_advisory_gate_is_not_a_gate) is now refused by name rather than silently dangling its citers. kb_text/2 is hand-maintained, like gate_predicate/1.').
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.').
work_followup('improvise-i34',browser_verification,'OpfsFs is compile-checked only -- no browser in this environment. storage_contract in storage.rs is the sequence it must satisfy. User will verify at the web MVP.').
decision('improvise-907',done_step,'chose to make Done a real confirm step rather than dropping the variant -- user decision. Warnings from build_model_with_warnings now render in the wizard panel instead of being crammed into a one-line status bar.').
decision('improvise-907',back_key,'Left is the back key on every step. Backspace is taken by the name buffer and the formula buffer; h is a printable character NameModel needs. Left is the only key free everywhere.').
decision('improvise-6sq',no_merge,'EditOrDrill reads ctx.display_value at Cmd time; EnterEditAtCursorCmd emits RebuildLayout then EnterEditAtCursor, which re-reads display_value_at_cursor after the rebuild. A merged command needs a rebuild_first/skip_drill flag -- the same fork renamed.').
decision('improvise-6mq',narrow_trait_over_vfs_crate,'chose Option B (own two-method trait) over the ticket-recommended Option A (vfs crate): the real surface is read/write one document, and the ticket own con for A -- sync-only while OPFS is async -- means adopting A would mean doing B later anyway').
decision('improvise-mae',cursor_is_per_client,'SetSelected/SetRowOffset/SetColOffset classed ViewOnly even though they write the shared persisted View, because one client scrolling must not move another viewport; the residual inconsistency is filed as improvise-8uz').
decision('improvise-jc8',flip_the_arc,'Arc<Keymap> moved out of ViewState and back into KeymapSet, which already owns every keymap. Motivated by a hard dependency cycle -- Keymap is in improvise-command, which will depend on improvise-protocol once reduce_full lives there -- not by serde.').
decision('improvise-i34',keep_storage_sync,'improvise-9x6 hosts App and persistence inside a browser worker, where FileSystemSyncAccessHandle is available. Async handle acquisition happens once at worker init; read/write are then synchronous, so the two-method sync trait stands and no async migration is needed. CONFIRMED 2026-09-20 by improvise-9x6, which built the worker on it -- with the caveat that OpfsFs itself has still never been executed.').
decision('improvise-h0b',fields_on_app_not_a_wrapper,'The ticket sketched a HostState { view, wizard, drill_state } wrapper. Rejected: App already IS the host struct, with a documented residue taxonomy (derived cache / runtime metadata / transient / config). Added a fifth tag, session-local, and put the two fields directly on App. This is what jc8 actually did too -- it moved keymap ownership to KeymapSet on App rather than introducing a wrapper.').
decision('improvise-a2x',buffer_key_becomes_string,'buffer_key: &\'static str -> String. Cow<\'static,str> would keep construction allocation-free but puts container plumbing in a public field, which AGENTS.md asks to keep out of logic. A BufferKey enum would close the name set but drags the buffers map key type, the script parser\'s buffer args (\"append-char formula\") and the hand-written \"search\" buffer with it -- out of scope. Mode construction is per-keypress, so seven small allocations are not a cost worth designing around.').
decision('improvise-a2x',k8h_enum_rejection_does_not_bite,'improvise-k8h rejected a Buffer enum on the DRIFT argument: each side would still name a variant, so the two could still disagree. That objection is about who writes the name, not what type it has -- buffer_of/1 still reads it off the mode either way. So k8h does not forbid an enum here; scope does.').
decision('improvise-8uz',none_of_the_three_options,'The ticket offered (a) move to ViewState and stop persisting, (b) View keeps the saved starting position and ViewState tracks the live cursor, (c) leave it. Scored against per_client + preserves_behaviour + no_dead_rationale, all three fail: (a) drops the per-view restore, (b) rests on a persistence that does not exist so its View field would be written and never read, (c) is not per-client at all. The synthesis (d) satisfies all three: View loses the fields outright, ViewState holds HashMap<view_name, ViewCursor>.').
decision('improvise-8uz',exemption_deleted,'the view_only_effects_leave_shared_state_alone exemption for SetSelected/SetRowOffset/SetColOffset is deleted: they no longer touch anything shared, so every ViewOnly effect now clears the same bar with no special cases. That exemption was the ticket\'s original symptom.').
decision('improvise-rml',semantic_color_not_ratatui,'the ticket specified display_short() -> (&\'static str, Color). Color is a ratatui type and Axis lives in improvise-core, so that signature is the layering violation forbidden(improvise_core,ui) names. Split into color() -> AxisColor plus short_label(), with draw.rs::axis_fg resolving the palette.').
decision('improvise-rml',is_data_axis_omitted,'the ticket also asked for is_data_axis() -> bool. No caller wanted it, so it was left out rather than added speculatively.').
decision('improvise-dz8',filter_persists_in_the_format,'added \"filter\" to axis_kind in improv.pest and to both sides of the writer/parser, even though Filter only ever occurs in the _Drill view which is never written. The alternative to supporting it was silently writing a Filter axis out as some other axis.').
decision('improvise-i34',no_indexeddb_fallback,'user decision 2026-08-31: skip the IndexedDB fallback unless recent Safari needs it. It does not -- Safari 16.4+ has synchronous access handles -- so the fallback is out of scope and the async question it would have forced does not arise.').
decision('improvise-i34',delete_is_deferred,'removeEntry is async and the trait is sync, so delete/1 drops the name from the index and queues the removal. A delete is therefore visible to read and list at once and lands in OPFS when commit_deletes is awaited. Invisible to a single worker; the alternative was making the whole trait async for one operation.').
decision('improvise-cqi',command_set_derived_not_invented,'the client->server half of Command is exactly the set of EffectKind::ViewOnly effects whose target is the portable slice. Effects touching host-local state (wizard, drill_state, storage, layout cache) are absent by construction -- improvise-h0b had already moved those off ViewState, so the eviction did the enumeration.').
decision('improvise-cqi',view_state_carries_its_own_active_view,'reduce_view runs on a client with no Workbook, and cursors is keyed by view name, so ViewState needs its own active_view. Kept identical to Workbook::active_view until improvise-jytm, which answered improvise-g0f: they MAY diverge. Workbook::active_view is now the document DEFAULT (what a fresh client opens, what .improv writes as Initial View) and ViewState::active_view is authoritative per client.').
decision('improvise-jytm',active_view_is_split_default_from_client,'the model is shared and the UI is not (user, 2026-09-19), so which view you are looking at is UI. Four things worth keeping. The split is made to AGREE WITH EffectKind rather than by a new mechanism: SwitchView/ViewBack/ViewForward became ViewOnly, which took the latter two off ModelCommand for free, because ModelCommand is DERIVED from the Model effects. A switch is therefore invisible to Dirty, so ClientSession OBSERVES its own view move (projected_view) rather than being told -- the cursor_epoch shape. App::resolve_view is the ONE answer to what a view name names in this document; a second copy of that fallback was the first thing review found. And the split CREATES a case it must then close: a DeleteView can take away the view another client is on, answered by observation in adopt_deleted_views rather than by a table of which commands remove views. Follow mode is deliberately NOT foreclosed: per-session UI state stays addressable, so a follow relationship can later be one session DERIVING its ViewState from another.').
decision('improvise-sef',retroactive_ticket,'The three kb commits landed with no beads issue at all. Filed and closed 2026-09-19 during a commit/tracker reconciliation rather than left untracked, because the CI job they add is a durable capability someone will later ask the provenance of. Every other commit back to cc3889d already named a ticket that is closed with a close_reason.').
decision('backlog-groom-2026-09-19',render_cache_name_collision,'improvise-cqi landed RenderCache as a flat grid-only wire type, while six open migration tickets (35e, n10, pca, 764, jb3, edp) were written against a fat per-widget cache of the same name. Two objects, one name, and nothing in the tree complains until someone writes from_app. Filed improvise-rkeq to decide the shape and made it block 35e rather than silently rewriting six tickets to match whichever shape a groomer preferred.').
decision('backlog-groom-2026-09-19',retroactive_tickets_for_untracked_merges,'PR #7 (cloud-session tooling) landed 18 commits with no beads issue, the same gap the kb commits had. Filed improvise-3qz2 closed. Rule being followed: work that adds a durable capability -- a CI job, a hook, a script someone will later ask the provenance of -- gets a ticket even after the fact; a chore commit does not.').
decision('beads-maintenance-2026-09-19',autoexport_owns_the_tracked_export,'user decision 2026-09-19: the tracked .beads/issues.jsonl carries issues ONLY and bd auto-export maintains it. export.include-memories was set to false via bd config set, which matches bd auto-export scope, so auto-export (export.auto + git-add) now writes and stages the file unattended. Memories live in Dolt and travel by bd dolt push, not by this file. Never hand-edit or git-checkout the file: a checkout fires the beads post-checkout hook, which rewrites AND stages it.').
decision('beads-maintenance-2026-09-19',memories_in_the_file_freeze_autoexport,'why the memories were dropped. bd 1.3.0 auto-export scope EXCLUDES memories even when config sets export.include-memories: true -- the shrink guard names them as \"outside auto-export scope\". While the file carried them, every mutating bd command hit the guard and auto-export never updated the file, so the guard was protecting the memories rather than malfunctioning. Two ways out: keep memories and maintain the file by explicit export forever, or drop them and let auto-export own it. Chose the second.').
decision('beads-maintenance-2026-09-19',plain_export_rewrites_the_bookkeeping,'bd export -o writes .beads/export-state.json as well as the file, recording the memory count it just wrote. So running the wrong export form desyncs the bookkeeping from config and changes what the shrink guard compares against later. Match the export form to export.include-memories rather than passing flags ad hoc.').
decision('beads-maintenance-2026-09-19',export_dependency_order_is_nondeterministic,'bd export emits each issue dependencies array in nondeterministic order, so a re-export of unchanged data still shows a ~23 line diff. Before committing, compare old against new with the dependencies arrays sorted; if nothing differs semantically, revert rather than commit the churn.').
decision('beads-maintenance-2026-09-19',flake_beads_pin_verified,'flake.nix pins beads to github:gastownhall/beads/v1.3.0 (flake.lock rev f45b249ce6b40ba62aecc03949e6371e8f7c79d8). nix build of that ref yields beads-1.3.0, matching the local bd 1.3.0 that writes .beads/issues.jsonl, so the pinned tool and the tracked export agree on format.').
decision('kb-normalization-2026-09-19',kb_normalized_to_mcp_canonical_form,'user decision 2026-09-19: kb.pl is maintained by the MCP, so it was saved in the MCP canonical form -- 563 lines to 391. Cost, measured by a no-op round-trip probe before committing: all 106 comment lines dropped, the 9 discontiguous directives dropped, multi-line rules reflowed onto one line, and anonymous _ arguments renamed to singleton variables (new trealla warnings, no semantic change). Every clause of every predicate survived and kb_consistent still passes. The chronological append shape that kb_issue_found(discontiguous) preserved is gone; clauses are now grouped by predicate, which is why the directives are no longer needed.').
decision('kb-normalization-2026-09-19',important_knowledge_is_asserted_never_commented,'user rule 2026-09-19: anything important goes in a fact, not a comment. Checked before normalizing -- every substantive comment was already asserted somewhere (cycle-safe closure in kb_issue_found(cycle_unsafe_closure), MCP leniency in kb_issue_found(mcp_lenience), the symbol-existence check in kb_external_check/1, fault injection in doctrine(check_the_checker)), so only section headers were lost. Re-run that check before any future save: prose that exists ONLY as a comment will not survive one.').
decision('kb-normalization-2026-09-19',audit_the_save_for_host_predicates,'saveSession serializes the whole clause database, including predicates the MCP runtime itself defines -- kb_issue_found(host_rpc_shims) records sleep/delay/console_log/js_eval/future/await_any/future_cancel being captured into this file that way once already. After every saveSession, grep the result for predicates that are not project knowledge and confirm the clause counts per predicate are unchanged, because loadProgram REPLACES a predicate rather than appending to it and a short payload silently drops the rest.').
decision('improvise-xks6',one_envelope_pair_for_every_deployment,'three envelope vocabularies for two deployments became one pair in improvise-protocol::frame. The question decided FIRST was whether the WORKER and the SOCKET want the same vocabulary; they do. Viewport is deployment-independent (the extent is not in ViewState and no effect produces it, so no derivation reaches it, and a worker hosts the same Sessions). Key is deployment-independent (a binding names an intent resolved against a CmdContext a thin client does not hold); the socket lacked it because key forwarding was never built. Commands(Vec) batches because a fan-out must arrive whole and in sequence, a property of the FOLD and not of the transport.').
decision('improvise-ondj',basis_check_is_a_type_not_a_visibility,'apply_command/reduce_full take Checked, a private-field newtype over &Command whose only constructor Checked::against(&App,&Command) runs stale_basis. pub(crate) was rejected: it closes only the OUT-OF-CRATE path, and reduce_full -- which has no production caller anywhere -- would have become dead code. The newtype also holds INSIDE the crate, which visibility does not.').
decision('improvise-fkfg',structural_and_data_staleness_are_different_answers,'RecordGone: nothing resolves, nothing to write to, nothing coherent to force -- force never creates structure. RecordMoved: resolves at another version, force is one keystroke. This is why ADR 002 put a MAINTAINED RecordHash index in DataStore rather than regenerating from the layout, which would have conflated scrolled-out-of-view with no-longer-exists.').
decision('improvise-gvnb',geometry_invalidates_every_cursor_but_moves_none,'App::cursor_epoch is bumped by reset_cursor and watched by Sessions::adopt_cursor_epoch. The trigger is the COUNTER MOVING, not the command variant -- which effects renumber the grid is a per-effect decision, so a variant table would be a second description free to disagree. What is broadcast is an INVALIDATION, never a position.').
decision('improvise-9x6',host_moved_not_mirrored,'Host contains no socket but lived in improvise-ws-server, which is tokio and native-only. The worker needs the same session loop and cannot depend on it. Moved to improvise_command::ui::host beside Sessions; ws-server re-exports. Re-implementing would have been the mirror improvise-xks6 had just deleted for the frames: a duplicated decision layer drifts invisibly. registry stayed on the server because a worker hosts one document.').
decision('improvise-9x6',worker_persists_on_its_own_behalf,'effect::Save is ViewOnly so no ModelCommand names it, and effect::SetCell does not set model_state.dirty (a separate MarkDirty effect does, only on the key path). So save-if-dirty never fires and no client on any transport can ask to save. The worker triggers on the command VARIANT instead, an over-approximation costing one redundant whole-document write. SUPERSEDED by improvise-gv9r: MarkDirty is gone, App::apply_effects raises the flag for any EffectKind::Model(_), and the trigger is now the flag -- see invariant(the_persist_trigger_is_an_observation).').
decision('improvise-9x6',document_named_out_of_band_not_in_a_frame,'the worker takes its document from its own URL query, the analogue of the socket naming it in the ws:// path. No fourth ClientFrame: a document is a resource, and the envelope pair stays shared with the socket deployment.').
decision('improvise-9x6',seeding_is_a_write_not_a_bypass,'a fresh origin has an empty OPFS, so SEEDING is the new problem, not loading. A seed goes in by Storage::write and is read back like any other document, so a fetched demo, an upload and a saved file share one parse path and a seed never clobbers an edit. User ruling 2026-09-20: there should be a VFS mechanism for this, and there already is one.').
decision('improvise-djm',websocket_path_kept,'the page spawns a worker by default and keeps ?ws= for improvise-ws-server, because the ws-server is the only way a TUI-hosted document is shared. Two transports cost little: they differ only in where strings come from, and the presence of the parameter is the whole selection mechanism.').
decision('improvise-bpq2',keymap_is_client_only,'keys become commands in the browser; the server must not know the keymap. User ruling, following from the-model-is-shared-the-UI-is-not. Buys two clients different bindings over one document. Where a client cannot compose a Command for a binding, ENRICH THE PROJECTION rather than resolving the key on the host; GridExtent is the first instance.').
decision('improvise-bck',demo_built_against_an_assertion,'web/demo.improv was written to satisfy an ADR 002 CellId assertion before it was written to look good; the assertion was proved discriminating against examples/demo.improv, where it names the aggregate at (0,0). Amended by improvise-ir7l: the original wording was every-projected-CellId-is-Record, which is now false and PASSED while false for the Profit column. The assertion is two-sided -- a formula-owned cell projects CellId::Derived -- and what the decision records is that the demo was built against the TYPE rather than against the axes, which stands.').
decision('improvise-shyt',index_narrows_primary_orders,'the alternatives both MOVE the cost rather than removing it: an ordered index value type pays on every set, and scanning the primary map is O(whole store) per query on a path all aggregation routes through. The hash walk was not cosmetic -- the drill snapshot froze its record list in that order while pending_edits are keyed by ROW INDEX, so the same drill in a second process addressed different records.').
decision('improvise-8xhy',refuse_at_emit_interim,'remedy 1 of 3, explicitly interim. Remedy 3 needs a scalar coordinate predicate the formula language lacks -- Filter gates a formula or an aggregate, it is not an Expr -- closes only the categorical third, and must first answer what the predicate means where context.get(cat) is None.').
decision('improvise-764',wizard_cache_has_no_projection,'a Projection is addressed to a widget on EVERY client, which is right exactly when the change is addressed to NO ONE session. A half-open dialogue belongs to precisely the session that opened it, so WizardCache is built and drawn locally with no Projection::Wizard. Broadcasting it would re-create improvise-tom1 on the RENDERING side.').
decision('improvise-n10',grid_widget_holds_no_model,'column totals MUST be projected rather than client-derived, because the sum runs over every row INCLUDING THE ONES A VIEWPORT SCROLLED PAST. The italic-aggregate rule reads CellId::Aggregate, which already carried the fact; the widget had been walking none_cats x kind.is_regular(), a second hand-rolled copy of project::cell_id.').
decision('improvise-d31',pipeline_before_artifact,'the Pages delivery pipeline was built against what exists rather than waiting on improvise-9x6, so the worker swap is a change to WHAT IS BUNDLED, not to how it is built and shipped -- two lines in build.sh and one cp in the assemble step.').
decision('improvise-cr3',render_decisions_are_made_in_rust,'the DOM half of the browser client is untested JavaScript, so every question with a SILENT wrong answer is answered in Rust under test and crosses as decided data. editable is a fact about the CellId variant -- character-for-character record_cell_at success condition -- so editable and composable-write hold by construction rather than by agreement. None is no-projected-cell, never aggregate (ADR 002).').
decision('improvise-0yq8',lockstep_survives_a_crate_move,'The acceptance criterion invited deleting tests/lockstep.rs if the move made it trivially true by type identity. It does not: lockstep drives reduce_key (Cmd/Effect, improvise-command) against resolve (improvise-protocol). Those are two derivations of one answer in two crates, one of which a browser cannot depend on. The move changed which crate resolve is spelled in, not that it mirrors something. Kept, with the reason written down, so the next reader does not re-open it.').
decision('improvise-e24v',three_listed_bindings_need_enrichment_after_all,'The ticket listed these as needing nothing added. Each is short exactly one fact and each wants a different answer (improvise-4bwa). toggle-prune-empty words its status off View::prune_empty, which is on the View inside the Workbook and in no cache -- a projection field would close it. delete-formula-at-cursor needs (target,target_category); FormulaCache carries the formula RAW TEXT, from which neither is recoverable without reimplementing parse_formula on the client. view-back emits ApplyAndClearDrill BEFORE ViewBack, and that effect is deliberately off the wire (improvise-3x3u) because its operand is another session uncommitted drill edits -- so it is not an enrichment at all, it is blocked on ADR 001 layer 2, and mirroring it by rebuilding the effect client-side would re-open exactly what 3x3u closed.').
decision('improvise-k099',the_wire_changed_to_match_the_effect,'effect::HelpPageNext clamps at the last page and HelpPagePrev saturates at zero; reduce_view Command::HelpPage arm WRAPPED. Two derivations of one fact, disagreeing, invisible because the help overlay h/l were upstream keys and no client had ever sent the command -- improvise-e24v made them a client and the lockstep test failed on the first press past the end (client 0, host 4). The WIRE was changed to match the EFFECT rather than the other way round: the effect is what ships in the TUI, and changing that is a product decision nobody asked for. Whether both should wrap instead is improvise-trd3, which requires changing both together.').
decision('improvise-vx3',fit_runs_in_the_cmd_not_the_effect,'a fit is decision logic -- a pure function of (Model, View, target, seed, FitConfig) mutating nothing -- so it runs in Cmd::execute and the effects carry its RESULT. Putting an expensive stochastic search inside Effect::apply would mix it into the one place the app mutates. That buys purity, not asynchrony: AC 4 is met on its \"bounded\" arm, measured at 6ms/12ms/105ms over 12/200/2000 records in release, and the fit stays a pure function a worker could be handed later').
decision('improvise-vx3',quality_arithmetic_lives_in_gp_tree,'R2 against the predict-the-mean baseline and the permutation-importance ranking moved out of fit_csv printing into gp_tree::fit as regression_quality/importance_ranking, and the CLI prints from them. A second consumer recomputing the number a user decides whether to believe a rule by would be the derivation-done-twice defect. Verified by the CLI output staying byte-identical on both the classification and regression paths, with an injected baseline fault proving the diff discriminating').
decision('improvise-vx3',the_outcome_carries_the_whole_request,'gotcha 37b says a fit takes the seed AND the config, and that pair is what gp_tree::data::Model records -- sufficient there because its other knobs are outside the loop. In fit_measure they are not: cross-validation draws from the same generator fit then draws from, so cv_folds moves the champion rather than only the evidence. FitOutcome carries the whole FitRequest; recording two of four fields documented the contract without meeting it').
decision('improvise-4zl7',the_aggregation_question_is_not_about_categories,'improvise-4zl7 asked what a CATEGORICAL split means where its coordinate is aggregated away. Copilot on PR #25 showed the question has nothing to do with the split being categorical: evaluate_aggregated aggregates each Expr::Ref before evaluating the IF, so aggregate-then-route differs from route-then-aggregate for ANY tree while a linear formula is unaffected. The ruling needed is \"what does a fitted rule mean in an aggregated cell\", for the feature as a whole').
decision('improvise-djoo',publish_the_context_prose_at_slash_docs,'The context/ prose is published at /docs/ on GitHub Pages, assembled by the same web/assemble.sh the pages-build job and harness/build.sh both run. Published: design-notes, design-principles, repo-map, the three ADRs, the seven context/code/ arguments, and the 501 test-evidence entries as their own last section. NOT published: context/plan.md, which is an agent prompt about a launch rather than a description of the program and cites a deleted context/SPEC.md, and context/cloud-agents.md, which is addressed to nobody who visits a website. Neither is referenced by a published document outside a code fence, so leaving them out dangles nothing -- checked, not assumed.').
decision('improvise-djoo',test_evidence_is_published_as_its_own_section,'The brief called 501 entries about individual tests questionable for a public site. Published anyway, because every one of the seven context/code/*.md documents names its evidence file -- omitting it would break the published prose in exactly the way the site is otherwise careful not to -- and because a written record of the injected fault behind each load-bearing test is not a thing most projects can show. Mitigated by being its own section, last, described as a contributor reference rather than a tour.').
decision('improvise-djoo',ticket_ids_resolve_inward,'Beads keeps its issues in a Dolt database on a git ref, so there is no public issue URL and linking ~190 citations anywhere external would produce ~190 404s. tickets.html is generated from .beads/issues.jsonl (already committed, already public) carrying id, status and title only -- descriptions are internal working notes. Because that export is passive and lags the database, an id it does not carry is emitted as plain text and listed as \"not exported\": the site never links to an anchor it did not itself generate.').
decision('improvise-djoo',the_ticket_index_publishes_open_bugs,'tickets.html publishes all 180 cited ids with status and title, open bugs included -- a user ruling, and a decision rather than a default. The data is already public in .beads/issues.jsonl in a repo anyone can clone; the page only makes it legible, and filtering it while the page lede links the raw JSONL would be theatre. Second-order reason to keep it unfiltered: a citation whose row was suppressed would be a link into a page that does not answer it, which is the failure the id-resolution design exists to avoid.').
decision('improvise-djoo',one_node_script_not_a_site_generator,'The landing page is hand-written HTML with no build step and one pinned CDN script; the prose site matches that posture with one zero-dependency node script. A static site generator would add a dependency tree, a lockfile and a toolchain in exchange for features this corpus does not use -- it has no front matter, no shortcodes and not a single markdown link. Every cross-reference in it is prose naming a path or an id, which is why the id and path resolution are the interesting parts and Markdown rendering is the boring one.').
decision('improvise-djoo',the_nav_disclosure_is_a_checkbox_not_details,'The doc-site sidebar collapses on a phone through a hidden checkbox plus a label, not <details>. <details> collapses correctly on a phone, but the sidebar layout must force it OPEN and a closed <details> hides its content through ::details-content, which \`display\` cannot override -- measured in Chromium, where the desktop sidebar came out collapsed to one link with its toggle hidden. With a checkbox the hiding is ours, inside the media query, so overriding it is ordinary cascade. General form: when a layout must force a disclosure open, own the hiding rather than borrowing the UA\'s.').
decision('improvise-864a',skip_a_superseded_pages_deploy,'Three merges within 20s queued three deploys on one Pages environment; only the newest kept its github-pages artifact and the other two runs went red on an empty fetch with a GREEN upload step. Chose to SKIP (compare GITHUB_SHA against the tip of main) rather than cancel: the concurrency block\'s own comment records that a deploy-pages cancelled midway can leave the deployment half-applied. Unresolved and flagged: why GitHub drops the losing runs\' artifacts -- the fix does not depend on the answer.').
decision('improvise-9c8c',gate_the_comment_cap_last,'The CI step enforcing the two-line comment cap and the test-evidence keys was held back until every unit of the comment wave had merged, so that the gate\'s first green run was evidence rather than vacuous.').
decision('improvise-kh8',landing_page_at_the_root,'USER RULING 2026-09-20: the landing page should be served at the root of the repository and link to the in-browser demo. Supersedes the agent\'s proposed landing_page_beside_the_app, which argued for the app at the root with the landing page at /landing/ on the grounds that the live app is the better front door; that argument is recorded as the reason the alternative was considered, and it was overruled. web/assemble.sh puts docs/index.html at the site root and moves the app bundle (index.html, app.js, style.css, worker.js, demo.improv, pkg/, worker-pkg/) under /app/, moving the two bundle dirs WITH the app so every relative path in the generated glue stays correct. The non-obvious cost: harness/tests/shell.spec.js navigates to /index.html six times, so a path move at the root silently retargets the whole shell spec at a different page. The deployed arrangement IS what the harness serves (improvise-vvl3), which is the property that makes the harness trustworthy and also the property that makes a layout change a test change.').
decision('pr82-fingerprint-guards-2026-09-20',a_test_reads_the_view_its_effect_writes,'App::view() resolves ViewState::active_view (this client) and Workbook::active_view() is the DOCUMENT DEFAULT; gotcha 29 makes them deliberately free to differ since improvise-jytm. A test asserting what a geometry effect did must read App::view(), the view the effect writes through view_mut(). They agree in a single-client fixture, so the mismatch is latent and survives review easily -- found only because a sibling guard added beside it used the other accessor, one test reading one view through two accessors.').
decision('improvise-l8rh',ambiguous_date_is_a_known_limit,'Ruled by the user 2026-09-20: leave detect_date_format first-match. A day-first default only moves which half of the world imports silently wrong; the honest fix surfaces the ambiguity and changes FieldProposal shape plus the wizard step, which is deliberate work rather than a patch. Tolerable because improvise-7yx made it deterministic. Pinned by an_ambiguous_date_is_read_american_first with a_day_first_only_value_settles_the_ambiguity as the discriminating arm.').
decision('improvise-kc7n',origin_default_is_loopback_not_the_projects_own_page,'improvise-ws-server refuses a handshake whose Origin is neither loopback nor named by --allow-origin. Loopback is the default because the shipped local recipe serves the page from http://localhost:8080 while the socket is on another loopback port, so the one case worth optimising for must need no flag. The deployed Pages origin is NOT hard-coded: it can only reach a plain-ws server through a TLS proxy, already a multi-step setup carrying an Origin allow-list, so one flag is an honest cost there where a hard-coded default would be a permanent hole for every other deployment.').
decision('improvise-kc7n',an_absent_origin_is_allowed_and_two_are_not,'Absent is DECIDED, not defaulted: every browser sends Origin on a handshake unconditionally, so absent is a non-browser client (curl, a native client, the crate own tests) that an address check already answers, and refusing it would break the repo own instrument for nothing. TWO Origin headers are refused, because HeaderMap::get is first-wins and a proxy that APPENDS rather than sets leaves two -- answering from the first lets the second be anything. An Origin that is not valid text is refused on the same ground. The residual cost is stated rather than guarded: a proxy that STRIPS Origin turns the check off, so the proxy own allow-list stays in README as defence in depth.').
decision('improvise-kc7n',an_origin_check_is_not_authentication,'It answers what page is DRIVING a client, never WHO is connecting, and anything that is not a browser page sends no Origin and is unaffected. improvise-kpnr is untouched by it and still open. Agent recommendation on the PR thread: a token in the URL, ws-server only, because new WebSocket(url) takes no headers so the URL is the one place a page can put a credential DELIBERATELY rather than ambiently; basic auth is ruled out on evidence, since a page cannot answer a challenge on a socket handshake.').
decision('improvise-x7cq',every_check_job_gates_the_deploy_is_a_gate,'pages-deploy must \`needs\` every other job in ci.yml. Stating the rule was not enough: a zz-new-check: job added during review of PR #83 was ungated and nothing noticed, and README carried a second copy of the job list with nothing holding it to the first. scripts/check-ci-graph.py reads the job keys and the needs list off indentation, TRACKING WHICH JOB A needs: BELONGS TO -- taking the first one seen would read another job the deploy list and pass, which is one of its five self-test graphs. README now states the rule and lists no jobs.').
decision('improvise-mcq4',cursor_frame_is_one_view_on_geometry_and_every_view_on_cell_data,'The LayoutGeometry arm hashes only the active view frame -- that is the held-key path the optimisation exists for, and other views cannot reframe there because view_mut() writes only the active view, with AddCategory/RemoveCategory/AddItem/RemoveItem already covered by view_shapes. The CellData arm hashes EVERY view, because a cell write can prune a row out of a view the host is not on. Measured: 0 percent on the held-key path, +5.7 percent on an 18k two-view SetCell, linear in view count and accepted. Absolute stated rather than only the ratio: the PR still roughly doubles held-key latency on large pruned documents, 3.04ms to 5.69ms at 18k, and 56ms at 90,000 cells is 3.4 frames.').
decision('improvise-ni6k',cursor_invalidation_is_observed_not_declared,'No effect calls reset_cursor. apply_effects diffs the grid frame after every model effect, INSIDE the loop, because a batch that reframes and then places the cursor loses the placement otherwise (four records-mode tests pin that position; two after the arms were split). Total over EffectKind::Model(_) by construction rather than by a maintained list -- the thirteen per-effect calls it replaced are gone, and the \"a geometry effect that forgets to reset is invisible in both directions\" hazard is closed for model effects. Totality remains conditional on CLASSIFICATION, which nothing makes total: a ViewOnly effect that renumbers the grid is invisible to all of it.').
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').
wire_carries(snapshot,bootstrap_only,'the ONE point ViewState is serialized: a value for a fresh client to fold later commands onto').
forbidden(improvise_core,ui).
forbidden(improvise_core,io).
forbidden(improvise_io,ui).
forbidden(improvise_io,commands).
forbidden(improvise_command,ratatui).
forbidden(improvise_command,crossterm).
forbidden(improvise_protocol,ui).
forbidden(improvise_protocol,io).
forbidden(improvise_protocol,commands).
forbidden(gp_tree,ui).
forbidden(gp_tree,commands).
forbidden(improvise_gp,ui).
forbidden(improvise_gp,io).
forbidden(improvise_gp,commands).
forbidden(improvise_wasm_client,ui).
forbidden(improvise_wasm_client,io).
forbidden(improvise_wasm_client,commands).
forbidden(improvise_worker_server,ui).
pipeline_step(1,keypress,keymap_lookup).
pipeline_step(2,keymap_lookup,cmd_execute).
pipeline_step(3,cmd_execute,effect_list).
pipeline_step(4,effect_list,effect_apply).
pure(cmd_execute).
mutating(effect_apply).
only_mutation_site('Effect::apply(&mut App)').
tracker(beads,bd,'bd ready / bd show / bd update --claim / bd close; no TodoWrite or markdown TODOs').
session_close(1,'file issues for remaining work').
session_close(2,'run quality gates if code changed').
session_close(3,'update issue status').
session_close(4,'git merge origin/main, bd dolt push, git push').
session_close(5,'clean up stashes and pruned branches').
session_close(6,'verify committed AND pushed').
session_close(7,'hand off context').
crate_of_layer(A,B):-layer(A,B).
where(A,B,C):-location(A,B,C).
concern(ui).
concern(io).
concern(commands).
concern(ratatui).
concern(crossterm).
may_use(A,B):-crate(A,C,D),concern(B), \+forbidden(A,B).
crate_depends_on(improvise_core,improvise_formula).
crate_depends_on(improvise_io,improvise_core).
crate_depends_on(improvise_io,improvise_formula).
crate_depends_on(improvise_command,improvise_core).
crate_depends_on(improvise_command,improvise_formula).
crate_depends_on(improvise_command,improvise_io).
crate_depends_on(improvise_main,improvise_core).
crate_depends_on(improvise_main,improvise_formula).
crate_depends_on(improvise_main,improvise_io).
crate_depends_on(improvise_main,improvise_command).
crate_depends_on(improvise_protocol,improvise_core).
crate_depends_on(improvise_command,improvise_protocol).
crate_depends_on(improvise_gp,gp_tree).
crate_depends_on(improvise_gp,improvise_formula).
depends_transitively(A,B):-depends_via(A,B,[A]).
depends_via(A,B,C):-crate_depends_on(A,B).
depends_via(A,B,C):-crate_depends_on(A,D), \+memberchk(D,C),depends_via(D,B,[D|C]).
illegal_dependency(A,B):-crate_depends_on(A,B),depends_transitively(B,A).
before_fixing(A,B):-work_done(A,bug_fix,C),doctrine(bug_first,B).
work_unblocked('improvise-8tk','improvise-git','its stated blocker -- a Sequence has no way to stop between steps -- does not hold: abort_effects carries across a flattened Sequence.').
work_unblocked('improvise-rml','improvise-dz8','adding Axis::Filter now means filling in one file rather than finding four scattered exhaustive matches').
work_unblocked('improvise-rml','improvise-dz8','adding the variant produced exactly ONE compile error outside axis.rs (the persistence writer), because rml had already collapsed the four display matches into one file. Evidence that the consolidation paid for itself.').
work_unblocked('improvise-cqi','improvise-gxi','reduce_full can now live in improvise-command taking a protocol Command, with no cycle').
name_source_kind(prototype_instance,safe,'Cmd::name() is read off a real instance; the compiler fixes the type').
name_source_kind(literal_string,unsafe,'a hand-written string with nothing tying it to the type the closure builds').
variant_named_cmd('ViewNavigate',forward).
variant_named_cmd('TileAxisOp',axis).
variant_named_cmd('ToggleGroupAtCursor',is_row).
variant_named_cmd('CommitAndAdvance',advance).
catches(prototype_instance,typo_in_name).
catches(uniqueness_test,wrong_variant_prototype).
catches(name_roundtrip_test,constructor_builds_other_name).
catches(bound_names_test,keymap_references_missing_command).
insufficient_for(name_roundtrip_test,wrong_variant_prototype).
needs_runtime_check(A):-variant_named_cmd(A,B).
record_row_identity(wide,coord_group_excluding_measure).
record_column(wide,category_col,rename_coordinate,all_cells_in_row).
record_column(wide,measure_col,write_cell_value,one_cell).
record_column(wide,new_measure_col,create_measure,no_cells).
record_edit_path(plain_records).
record_edit_path(drill_pending).
record_obligation(A,B,C,D):-record_edit_path(A),record_column(wide,B,C,D).
storage_op(read,'whole document in').
storage_op(write,'whole document out').
storage_op(list,'enumerate document names in a directory').
storage_op(delete,'remove one document').
storage_backend(physical_fs,native,std_fs).
storage_backend(memory_fs,tests,hashmap).
storage_backend(opfs_fs,'crates/improvise-io/src/opfs.rs','wasm32 only; Storage over the Origin Private File System').
effect_kind_class(view_only,'never projected to other clients').
effect_kind_class(cell_data,'cell values, categories, items').
effect_kind_class(layout_geometry,'axis, page, hidden, grouping, format, active view').
effect_kind_class(whole_workbook,'workbook replaced wholesale').
derived_by_fold(view_state,command_stream,reduce_view).
derived_by_fold(render_cache,projection_commands,reduce_view).
derived_by_fold(view_state,command_stream,'both ends run reduce_view over the same commands, so the server mirror cannot drift from the client').
derived_by_fold(render_cache,projection_commands,'local to each client; serialized only to ride in the bootstrap Snapshot').
wasm_bundle_crate(improvise_core).
wasm_bundle_crate(improvise_formula).
wasm_bundle_crate(improvise_io).
wasm_bundle_crate(improvise_command).
wasm_bundle_crate(improvise_protocol).
wasm_bundle_crate(improvise_wasm_client).
wasm_bundle_crate(improvise_worker_server).
not_in_wasm_bundle(improvise,'terminal binary; depends on crossterm').
wasm_checks_clean(all_bundle_crates,no_feature_flags_needed).
wasm_runtime_hazard(instant_now,'panics on wasm32-unknown-unknown',fixed_with(web_time)).
wasm_runtime_hazard(std_fs,'compiles on wasm but fails in a browser',tracked_as('improvise-fuj')).
wasm_non_hazard(dirs,'declared in root Cargo.toml but used nowhere').
wasm_non_hazard(chrono_local_now,'never called').
wasm_non_hazard(std_env,'only in a test').
wasm_non_hazard(flate2,'compiles for wasm as-is').
storage_injection_point('App::storage','Box<dyn Storage>','chosen by the host at construction').
storage_host(native_cli,physical_fs).
storage_host(tests,memory_fs).
storage_host(browser_worker,opfs,planned('improvise-i34')).
opfs_api(promise_based,async,'getDirectory, getFileHandle, createWritable').
opfs_api(sync_access_handle,sync,'createSyncAccessHandle; worker-only; read/write/flush/close are blocking').
groom_check(cycles,'reaches/2 over blocks edges; found none').
groom_check(priority_inversion,'a blocker with LOWER priority than what it blocks makes bd ready unworkable in priority order; rule = priority non-decreasing along dependency edges').
groom_check(unrecorded_dep,'prose naming another issue is a CANDIDATE, never a fact: of 21 one-way mentions only 2 were real dependencies, several pointed the opposite way and were already recorded correctly').
groom_check(stale_citation,'file:line references rot; resolve each against the tree and prefer symbol names when correcting').
groom_check(dissolved_premise,'grep for the pattern an issue targets before working it -- improvise-ont described 11 call sites of a pattern that no longer occurs anywhere').
groom_check(dissolved_premise_test_gap,'a test-gap ticket rots differently from a code ticket: the gap can be closed by someone else work without anyone touching the ticket. Re-run each item against the test names before working it -- 2 of 7yx five items were already covered, by jc8 and by an import fix.').
kb_issue_found(claude_md_tail_is_generated_not_duplicated,'The ~47-line beads tail is byte-identical in AGENTS.md and CLAUDE.md, which reads as the duplication doctrine(a_fact_stated_twice_is_changed_by_grep) forbids. It is not: both copies sit inside <!-- BEGIN BEADS INTEGRATION v:1 profile:minimal hash:ca08a54f --> markers, written by bd setup codex and bd setup claude respectively, and refreshed by bd against that content hash. A process change there is one tool invocation, not two hand edits, and replacing either copy with a pointer makes bd setup <recipe> --check report it modified and the next run re-add it. Checked 2026-09-20 after a doc review proposed the pointer; declining is the right answer and this fact exists so the next reviewer does not re-propose it.').
kb_issue_found(host_rpc_shims,'sleep/delay/console_log/js_eval/future/await_any/future_cancel were the MCP runtime own predicates, captured into this file by a saveSession. Removed: not project knowledge, and the source of an overwriting warning on every consult. If saveSession re-adds them, strip them again.').
kb_issue_found(may_depend_on_type_confusion,'may_depend_on(A,B) :- crate(A,C,D), \\+forbidden(A,B). C and D were singletons and B was never constrained, so may_depend_on(improvise_core, banana) succeeded. forbidden/2 second argument is a CONCERN, not a crate. Replaced by may_use/2 over an explicit concern/1, plus real crate_depends_on/2 facts read from the Cargo.toml files.').
kb_issue_found(before_fixing_dead_guard,'before_fixing(A,B) :- A=C, doctrine(bug_first,B). A=C bound A to a fresh variable and constrained nothing, so the rule succeeded for any first argument. Now requires work_done(Bug,bug_fix,_).').
kb_issue_found(discontiguous,'nine predicates are appended chronologically and so are scattered; added :- discontiguous declarations rather than reordering, because the chronological shape is the point.').
kb_issue_found(checker_never_failed,'a consistency check that has only ever reported success is untested. Inject one fault per kb_error class and confirm each is reported before trusting it.').
kb_issue_found(cycle_unsafe_closure,'the first depends_transitively/2 was a naive transitive closure, so illegal_dependency/2 -- whose whole purpose is detecting an inverted edge -- looped forever on exactly that input. Found by fault injection; now carries a visited set.').
kb_issue_found(mcp_lenience,'the prolog MCP server accepted \`:- discontiguous foo/1.\` which trealla rejects (needs parens). Validate kb.pl with trealla itself, not only through the MCP session.').
kb_issue_found(static_str_blocks_deserialize,'a borrowed &\'static str field makes Deserialize unimplementable, and rustc reports it on every ENCLOSING enum variant rather than at the offending field -- five near-identical \"lifetime may not live long enough\" errors pointing at MinibufferConfig, none naming buffer_key. Read past the reported site to the borrowed field.').
kb_issue_found(ticket_premise_false_8uz,'improvise-8uz asserted selected/row_offset/col_offset were \"PERSISTED in .improv\". They were not: improv.pest has no cursor production and format_md emitted none. The claim shaped the ticket\'s whole option set, so it was checked with a test before any of the options was weighed.').
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).
functional(groom_check/2).
functional(kb_issue_found/2).
functional(crate/3).
functional(wasm_runtime_hazard/3).
kb_sentinel(zzz_not_a_real_term).
known_crate(A):-crate(A,B,C).
kb_error(layer_names_unknown_crate(A,B)):-layer(A,B), \+known_crate(B).
kb_error(forbidden_names_unknown_crate(A)):-forbidden(A,B), \+known_crate(A).
kb_error(forbidden_names_unknown_concern(A,B)):-forbidden(A,B), \+concern(B).
kb_error(depends_names_unknown_crate(A,B)):-crate_depends_on(A,B), \+ (known_crate(A),known_crate(B)).
kb_error(layering_inverted(A,B)):-illegal_dependency(A,B).
kb_error(rule_too_permissive(may_use/2)):-kb_sentinel(A),may_use(B,A).
kb_error(rule_too_permissive(before_fixing/2)):-kb_sentinel(A),before_fixing(A,B).
kb_error(rule_too_permissive(crate_depends_on/2)):-kb_sentinel(A),crate_depends_on(A,B).
kb_error(conflicting_values(A/2,B)):-functional(A/2),call(A,B,C),findall(D,call(A,B,D),E),sort(E,F),F=[G,H|I].
kb_error(conflicting_values(A/3,B)):-functional(A/3),call(A,B,C,D),findall(E-F,call(A,B,E,F),G),sort(G,H),H=[I,J|K].
kb_error(followup_from_unrecorded_work(A)):-work_followup(A,B,C), \+work_done(A,D,E).
kb_error(unblocked_by_unrecorded_work(A)):-work_unblocked(A,B,C), \+work_done(A,D,E).
kb_error(field_misplaced_in_view_state(A)):-session_field(A,view_state,B), \+view_state_admits(A).
kb_error(field_evicted_without_cause(A)):-session_field(A,app,B),view_state_admits(A).
kb_error(dangling_citation(A,B,C)):-kb_text(A,D),cited_name(D,B,C), \+citation_resolves(B,C).
kb_consistent:- \+kb_error(A).
kb_report:-setof(A,kb_error(A),B)->length(B,C),format([k,b,:,' ',~,w,' ',p,r,o,b,l,e,m,'(',s,')',~,n],[C]),forall(member(A,B),format([' ',' ',-,' ',~,w,~,n],[A]));write('kb: consistent'),nl.
kb_external_check('grep -oE \"^location\\([a-z_]+,\'[^\']+\',\'[^\']+\'\\)\" prolog-sessions/kb.pl # then confirm each file/symbol still exists').
portable_origin(improvise_core).
portable_origin(improvise_formula).
portable_origin(improvise_command).
portable_origin(std_plain).
session_field(mode,view_state,improvise_command).
session_field(status_msg,view_state,std_plain).
session_field(cursors,view_state,improvise_command).
session_field(search_query,view_state,std_plain).
session_field(search_mode,view_state,std_plain).
session_field(formula_panel_open,view_state,std_plain).
session_field(category_panel_open,view_state,std_plain).
session_field(view_panel_open,view_state,std_plain).
session_field(cat_panel_cursor,view_state,std_plain).
session_field(view_panel_cursor,view_state,std_plain).
session_field(formula_cursor,view_state,std_plain).
session_field(yanked,view_state,improvise_core).
session_field(tile_cat_idx,view_state,std_plain).
session_field(view_back_stack,view_state,improvise_command).
session_field(view_forward_stack,view_state,improvise_command).
session_field(help_page,view_state,std_plain).
session_field(expanded_cats,view_state,std_plain).
session_field(buffers,view_state,std_plain).
session_field(transient_prefix,view_state,improvise_command).
session_field(wizard,app,improvise_io).
session_field(drill_state,app,improvise_command).
holds_shared_ptr(drill_state).
uncommitted_writes(drill_state).
half_open_dialog(wizard).
portable(A):-session_field(A,B,C),portable_origin(C), \+holds_shared_ptr(A).
snapshot_safe(A):-session_field(A,B,C), \+uncommitted_writes(A), \+half_open_dialog(A).
view_state_admits(A):-portable(A),snapshot_safe(A).
axis_property(row,navigable).
axis_property(column,navigable).
axis_property(page,navigable).
axis_property(page,constrains).
axis_property(filter,constrains).
axis_property(none,neither).
answers(filtering_cats,'what data is visible',constrains).
answers(categories_on_page,'what the user can page through',navigable).
answers(tileable_cats,'what the user can reassign',navigable).
consumer(grid_layout,filtering_cats).
consumer(add_record_row,filtering_cats).
consumer(page_cycling,categories_on_page).
consumer(tile_bar,tileable_cats).
consumer(move_tile_cursor,tileable_cats).
consumer(tile_axis_op,tileable_cats).
opfs_async(get_directory,worker_init,'OpfsFs::open').
opfs_async(get_file_handle,per_document,'OpfsFs::attach').
opfs_async(create_sync_access_handle,per_document,'OpfsFs::attach').
opfs_async(directory_iteration,refreshed,'OpfsFs::refresh populates the index list/1 answers from').
opfs_async(remove_entry,deferred,'delete/1 queues it; OpfsFs::commit_deletes drains at end of batch').
opfs_sync(read).
opfs_sync(write).
opfs_sync(truncate).
opfs_sync(flush).
opfs_sync(close).
browser_fact(sync_access_handle_safari,'Safari 16.4+ supports createSyncAccessHandle, and all current browsers implement the handle methods synchronously despite an early spec that wrongly called them async. Checked 2026-08-31, so the IndexedDB fallback improvise-i34 called for is not needed.').
browser_fact(sync_access_handle_worker_only,'available only in dedicated Web Workers, and exclusive per file entry: a second handle on the same file fails until the first is closed. Hence attach closing what it replaces, and detach existing at all.').
citation_marker('doctrine(',doctrine).
citation_marker('invariant(',invariant).
citation_marker('decision(',decision).
kb_text(doctrine(A),B):-doctrine(A,B).
kb_text(invariant(A),B):-invariant(A,B).
kb_text(decision(A,B),C):-decision(A,B,C).
kb_text(work_done(A,B),C):-work_done(A,B,C).
kb_text(work_followup(A,B),C):-work_followup(A,B,C).
kb_text(kb_issue_found(A),B):-kb_issue_found(A,B).
lower_or_digit(A):-char_code(A,B),B>=97,B=<122.
lower_or_digit(A):-char_code(A,B),B>=48,B=<57.
name_char(A):-lower_or_digit(A),!.
name_char('_').
name_shaped(A):-atom_chars(A,B),B=[C|D],lower_or_digit(C),forall(member(E,B),name_char(E)),memberchk('_',D).
citation_token(A,B):-(sub_atom(A,C,1,D,')')->true;C=999),(sub_atom(A,E,1,F,',')->true;E=999),G is min(C,E),G>0,G<120,sub_atom(A,0,G,H,B).
cited_name(A,B,C):-citation_marker(D,B),sub_atom(A,E,F,G,D),sub_atom(A,H,G,0,I),citation_token(I,C),name_shaped(C).
cited_name(A,decision,B):-sub_atom(A,C,D,E,'decision('),sub_atom(A,F,E,0,G),decision_name(G,B),name_shaped(B).
citation_resolves(doctrine,A):-doctrine(A,B).
citation_resolves(invariant,A):-invariant(A,B).
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.').