Files
improvise/prolog-sessions/kb.pl
T
Edward Langley 6f9efcf2e8 docs: improvise-xps8 describes a bug that does not exist — turn it into a guard
Review findings on PR #30, all three applied.

1 [P1]. The claim that the handshake watchdog overwrites a genuine mid-session
disconnect 8s later is FALSE on any tree that has PR #21. `close` routes BOTH
arms through `setShellFailure` (app.js:248-250), which clears the timer
synchronously (app.js:165-169).

It was believed because it was measured honestly — against a tree predating
PR #21 — and then reported as "surviving" that PR on the strength of a
simulation that re-created only the `opened` flag and not the other half of the
fix, the routing. A simulated fix that is not the fix you named reproduces a
bug the real code does not have, and reads exactly like a confirmation.

Rather than delete the claim, execute the property it denied: the
open-then-dropped test now runs past HANDSHAKE_TIMEOUT_MS + 2s and asserts
"sent no document" never appears. Proved discriminating by removing
`setShellFailure`'s `clearTimeout` — the assertion then fails with exactly the
message xps8 described. gotcha 48 and the spec's comment block corrected; the
ticket is cited in neither. Recorded as
doctrine(a_simulated_fix_must_be_the_fix_you_named), the sibling of
an_injected_fault_must_be_the_fault_you_named that fails the other way: a wrong
fault proves a check that cannot fire, a wrong simulated fix proves a bug that
does not exist.

2 [P1]. The previous commit created the contradiction it was meant to remove:
design-principles recorded the measured ~2.2s while kb.pl still said "under 2
seconds". The ambiguity between per-binary and wall clock is what let it drift,
so both now state two numbers — no single binary over 2s, wall clock is the
sum, measured 1309 tests / 33 binaries / ~2.2s / longest 1.42s — in kb.pl,
design-principles and repo-map alike. Also 16 -> 17 in doctrine(binding_dump_diff).

3 [P2]. The new audit exclusion was unanchored, so `improvise-gp` was a prefix
match that would silently exclude a real future ticket. Anchored `^…$`. It
immediately caught the example id the explanatory paragraph itself spelled out
— a recipe is code, twice over.

CLAUDE.md: declined, with evidence. Both copies of the duplicated tail sit
inside `<!-- BEGIN BEADS INTEGRATION v:1 profile:minimal hash:ca08a54f -->`
markers written by `bd setup claude` and `bd setup codex` and refreshed against
that hash. A process change there is one tool invocation, not two hand edits,
and a pointer makes `bd setup <recipe> --check` report it modified. Recorded as
kb_issue_found(claude_md_tail_is_generated_not_duplicated) so it is not
re-proposed.

kb.pl: doctrine 54->55, kb_issue_found 11->12; invariant 51, decision 47,
location 34 unchanged. kb_consistent passes.

cargo test --workspace: 1309 passed, 0 failed, 2 ignored.
harness (Playwright): 12 passed, 3 skipped (webkit has no OPFS).
Dead-ID audit with the anchored recipe: 157 tokens, all resolve.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 01:11:46 -07:00

488 lines
87 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 and is never run.').
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').
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_records,'web/demo.improv projects only CellId::Record; guarded twice, in tests/web_demo_model.rs against grid_of and in post_message.rs against the real seed-VFS-open-connect-Snapshot chain.').
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(opfs_is_run_but_bindings_is_not,'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. Never instantiated by any harness: bindings.rs.').
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(kb_is_maintained_through_the_mcp,'prolog-sessions/kb.pl is the MCP serialized clause database, not a hand-written source file. Add facts by loadSession, then loadProgram, then saveSession -- never by appending text to the file. Note loadProgram REPLACES a whole predicate rather than appending to it, so the program you load must carry every existing clause of every predicate it touches, or the rest are silently dropped. Verify counts with findall/length before saving. assertz is NOT an escape hatch: a consulted predicate is static and assertz raises permission_error, so the whole-predicate reload is the only route.').
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 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): Sessions::apply moved workbook.active_view and then only the addressing session mirror, but grid_of builds EVERY client grid from workbook.active_view(), so a bystander left naming the outgoing view had its cursor read under a stale key and was projected the new view rows positioned by the old view scroll. The fix gives every mirror the ACCEPTED value -- not the requested one, which also corrects a client that optimistically folded a name the document turned down -- and apply_switch takes no SessionId at all, because the change is addressed to no one session and that absence is the statement. Ask of any shared value: who else derives something from 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 to other clients is ToOthers exactly when the asker folded the command optimistically; otherwise ToAll. A Command::Model is never folded optimistically, so every relay out of apply_model is ToAll. Copying apply_switch ToOthers into apply_model left the asker own slice naming the outgoing view while its server mirror named the incoming one -- and a test asserting through a what-do-the-others-hear helper cannot see it, because that helper looks away from the asker by construction.').
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.').
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.').
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_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 by App::switch_to_view and App::install_workbook; whether they MAY diverge is improvise-g0f.').
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.').
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 every-projected-CellId-is-Record (ADR 002) before it was written to look good; the assertion was proved discriminating against examples/demo.improv, where it names the aggregate at (0,0).').
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).').
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. Two writers only (switch_to_view, install_workbook), guarded by the_two_active_view_names_never_diverge.').
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_consistent :-
\+kb_error(A).
kb_report :-
setof(A,kb_error(A),B)->length(B,C),format("kb: ~w problem(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.').