docs(context): record #303-#307 in the kb and answer improvise-at5k

Facts for gtn7.1, cgqt/9bit/4klp, felo.1 and the merge-only history
rule; the two doctrines that read as licensing a rewrite now forbid it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
This commit is contained in:
Edward Langley
2026-09-24 20:16:25 -07:00
parent c673fa3b61
commit d5fd1ec824
+18 -2
View File
@@ -53,6 +53,7 @@ location(view_state_membership,'crates/improvise-command/src/ui/app.rs','ViewSta
location(axis_presentation,'crates/improvise-core/src/view/axis.rs','plain_label/glyph/short_label/initial/color/next on Axis; AxisColor semantic palette').
location(axis_color_resolution,'src/draw.rs','axis_fg maps AxisColor to a ratatui Color, beside minibuf_fg which does the same for MinibufColor').
location(tui_truncate,'src/ui/text.rs',truncate).
location(grid_width,'crates/improvise-command/src/ui/viewport.rs','grid_width(term_width, side_panel_open) with SIDE_PANEL_WIDTH = 32 and MIN_GRID_WIDTH = 40; reached through App::grid_width, read by draw.rs::draw_content and App::cmd_context (improvise-gtn7.1)').
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\")').
@@ -141,6 +142,9 @@ invariant(fmt_gate_exit_two_means_the_gate_did_not_run,'scripts/fmt-workspaces.s
invariant(a_frame_group_level_names_a_category,'An AxisFrame deserialises through UncheckedAxisFrame and REFUSES a FrameGroup or GroupEnd whose level is >= cats.len(). A level naming no category is a malformed frame from the wire, never something to clamp, so the check is at the one door every frame arrives through. context/code/protocol.md section 3b.').
invariant(a_grid_evaluates_only_through_its_own_evaluator,'GridLayout::evaluator builds a GridEval whose fold memo that layout built from its own none_cats and hidden items, and a grid evaluates only through it, so no other grid memo can reach its cells or totals (improvise-opbo, where a memo built by another layout silently lent its aggregation and hidden set). prune_empty shares one GridEval across its whole emptiness test rather than a fresh memo per cell (improvise-metf).').
invariant(an_unprojected_cell_is_unknown,'Every client read of the grid cache goes through projected_at, which subtracts the frame origin (AxisFrame::first) before indexing. A position outside the window the cache holds is UNKNOWN, never the value at the same cache index, and a cell op on it waits. Reading the cache through offsets the client had scrolled ahead to made a pipelined edit write a different cell than the TUI would (improvise-7mro).').
invariant(default_duplicate_policy_is_refuse,'ImportPipeline::on_duplicate defaults to DuplicatePolicy::Refuse, so any path that never asked the question, the headless CLI included (--on-duplicate defaults to refuse), errors naming the collisions rather than summing. On ResolveDuplicates, Refuse blocks advance with a message; the build under Refuse returns an error when collisions exist (improvise-cgqt).').
invariant(a_collision_is_a_cell_written_twice,'A collision is a cell -- a coordinate tuple AND a measure -- written by two or more records, never merely a shared tuple: records with disjoint measures on one tuple write different cells and collide with nothing, and only the records that share a cell are listed and numbered (improvise-9bit). Plan::place is the one placement pass, used by both collisions() and build_model_with_warnings, so the question asked and the model built cannot disagree.').
invariant(wizard_draws_are_clipped_to_the_panel,'Every import-wizard write goes through Panel in src/ui/import_wizard_ui.rs, which clips to the panel rows and width, so no step can draw past the buffer; text is measured in cells by ui::text::truncate and wrap, never in bytes (review B1/B2 and a Codex P2 on PR #304).').
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).').
@@ -222,7 +226,6 @@ doctrine(a_destructive_default_warns_after_the_write,'bd update --notes REPLACES
doctrine(parallel_actors_need_distinct_names,'bd\'s --actor defaults to $BEADS_ACTOR, then git user.name, then $USER, so a wave of agents sharing a machine shares an actor and the claim guards cannot tell them apart -- two agents claim one bead and neither is told. Exit 13 means another actor won the race; that is the mechanism working, not a failure to retry around. User instruction, 2026-09-20.').
doctrine(a_stale_gotcha_asserts_the_negation,'repo-map gotcha 17 said float equality was \"currently mixed\" and named the div-by-zero guard as the exact-comparison half, long after both had been unified on FLOAT_EQ_EPSILON. A doc that goes stale does not merely omit -- it tells the reader not to assume the thing that is now true. This is why the orchestrator doc pass VERIFIES each claim against the tree rather than applying a queued edit.').
doctrine(an_advisory_gate_is_not_a_gate,'scripts/check-comment-runs.py was written, fixed seven times, and quoted by seven agents as their definition of done while being invoked by NO workflow. A rule enforced only by the agents who agreed to it is a convention. The gate landed last, deliberately, because a gate that goes green before the work is done is the same class of lie the checker itself carried.').
doctrine(a_review_names_the_sha_it_reviewed,'A review of a superseded commit is worse than no review: it reads as a pass. So the reviewer names the exact SHA, the author does not rewrite history while a review is in flight and announces any push, and a re-review is of the delta. This matters more now that adversarial agents are the ONLY gate -- Copilot\'s quota is exhausted for the rest of 2026-09 and its request is a SILENT no-op (the mutation returns success and registers no reviewer), which is itself an instance of doctrine(an_advisory_gate_is_not_a_gate).').
doctrine(review_is_requested_not_inherited,'No PR is reviewed because it exists. Requesting review is a step in the author\'s own definition of done, ranked beside \"tests pass\", not a courtesy notification afterwards. The failure mode it prevents is the quiet one: an agent finishes, opens a PR, reports success and waits on a review that was never going to come -- indistinguishable, from the outside, from a PR waiting its turn. Note the shape repeats: an ABSENT gate and a SILENTLY no-op request both read as \"in progress\", which is an_advisory_gate_is_not_a_gate seen from the requester\'s side. The user had turned Copilot AUTO-review off, which is the actual explanation for PRs #37/#41/#42 merging unreviewed; the orchestrator had attributed that to quota, and the two are independent facts.').
doctrine(construction_is_not_loading,'A lazily-loading embedded player proves nothing by existing. asciinema-player with autoPlay:false does not FETCH its cast until the viewer clicks, so a page whose every cast path 404s constructs the full set of players and throws no page error. The agent\'s own check passed on a page broken exactly that way (./casts/ -> ./cats/); pressing play returned 0 rendered lines and four 404s. Generalises: any check on a lazily-initialised embed must trigger the load it is about.').
doctrine(a_deployed_path_is_a_grep_across_the_tests,'Moving where something is served is not a build-script change, it is a grep across the TEST surface. The landing-page inversion (user ruling on improvise-kh8) cost one line in web/assemble.sh and six navigations in harness/tests/shell.spec.js, a CI-gating job the agent did not own. Nothing about \"serve the landing page at the root\" says \"a browser test job goes red\". In this repo the harness is the surface LEAST likely to be in the mental model, and for a structural reason: it is not a workspace member and cargo test never touches it, so every habit the repo has built is blind to it. Cross-reference: the harness serves the DEPLOYED arrangement on purpose (improvise-vvl3), which is what makes it trustworthy and also what makes a layout change a test change.').
@@ -253,7 +256,6 @@ doctrine(a_message_assertion_is_a_uniqueness_claim_about_the_whole_vocabulary,'A
doctrine(run_the_gate_ci_runs,'A local gate differing from CI in its flags or its output filter can only fail in directions the defect is not. On PR #71 the author ran clippy WITHOUT -D warnings and grepped for a lint name in brackets, which cannot match the bare \"warning:\" unused_imports emits, so the check printed zero and read clean while CI went red. Worse, the repo own code-review skill specified the same flagless invocation, so it was the INSTRUCTION rather than one person shell mistake -- a reviewer following it would report clean and be wrong. Invoke the gate exactly as the workflow does, flags and all.').
doctrine(an_observation_correct_about_what_changed_can_be_wrong_about_when_it_is_read,'improvise-pdvm/75ld replaced a DECLARED classification with an OBSERVED one, which is right. The cost landed on WHEN the observation is read: comparing document_config once AROUND the batch is correct about what changed and wrong about ordering, because Save clears dirty INSIDE the loop, so [SetNumberFormat, Save] saved and then re-raised the flag. The fix is position, not suppression -- the comparison moved into the loop, using |= so it only ever sets. The asymmetry that decides it: nothing ever CLEARS layout_version, so an end-of-batch comparison is right there, while dirty has an effect that unsets it mid-batch. The discriminating probes rule out BOTH wrong fixes: [SetNumberFormat] alone must still be dirty, or LayoutGeometry has been demoted and the document never saves; and [Save, SetNumberFormat] must be dirty, or the fix is a blanket never-dirty-after-save.').
doctrine(a_siblings_unmerged_pr_is_invisible_to_bd_search,'AGENTS.md requires a bd search before filing. In a parallel wave that is not sufficient: a fix living in a sibling agent UNMERGED PR is in no tracker state at all, so the search correctly returns nothing and the duplicate is filed anyway. Observed 2026-09-20 -- the tooling agent filed improvise-v5pc to restore a deleted help assertion while the harness agent was landing exactly that in PR #72. Neither agent erred. Check OPEN PRS as well as the tracker, and have the orchestrator reconcile filings against merged work at wave end rather than trusting each agent to have seen the others.').
doctrine(a_hard_reset_discards_knowledge_written_to_the_working_branch,'The orchestrator wrote doctrines to kb.pl on local main, pushed each as a branch, then ran git reset --hard gh/main to resync -- twice -- and silently discarded six clauses that existed only in the unmerged local commits. Nothing failed: kb_consistent passed on the reduced file, the counts simply read lower, and the loss was found only by grepping for clause names remembered from the session. Two rules follow. Write a fact ONCE, on the branch that will carry it to main, not on a local main that is going to be resynced. And after any reset or force-move of a branch holding knowledge, re-count the predicates and diff clause NAMES against what you believe you wrote -- a total that merely looks plausible is the same non-evidence a passing test count is.').
doctrine(a_checker_that_reads_headings_cannot_see_a_contradicting_body,'check-evidence validates that a heading names a test that exists. It says nothing about the PROSE under the heading, which is what a human actually reads. Demonstrated on this repo 2026-09-20: renaming a retired entry to the test that had been restored made the checker green while the body three paragraphs down still said \"The test no longer exists\" and \"nothing replaced the assertion\" -- one entry making mutually exclusive claims, and the gate could not see it because the gate was never about that. Found by review, not by the checker. The general form is that a mechanical check licenses exactly the claim it makes and no neighbouring one, so a rename is a read of the whole entry rather than an edit of its first line -- a_fact_stated_twice_is_changed_by_grep applied WITHIN a document rather than across them.').
doctrine(a_cost_note_names_the_half_the_author_was_thinking_about,'Twice now a reviewer has been asked to check a claim of the form \"X is now O(n) where it was O(1)\", and both times the interesting answer was not whether the claim was true but WHICH SIDE OF IT A READER WOULD ACT ON. On PR #76 the author wrote that the cost is \"O(cells) per CellData OR LayoutGeometry effect, where it was per LayoutGeometry effect\" -- which reads as though CellData is the newly burdened path. Measured at 18,000 cells it is the reverse: CellData was ALREADY O(cells) through record_fingerprints, so widening it is a constant factor (+27%), while LayoutGeometry went from O(config) to O(cells + config) and got 33x slower, 78us to 2.63ms, on the held-key-repeat operations. Not dishonest -- the author was thinking about WHICH EFFECTS NEWLY QUALIFY rather than WHICH PATH NEWLY CHANGED COMPLEXITY CLASS, and those are different questions with different answers. A cost note is worth a measurement precisely when the author can say confidently which half is worse, because that confidence is what stops anyone measuring.').
doctrine(name_the_test_that_fails_when_only_this_edit_is_reverted,'A repair is usually N edits, and the failure mode is that N-1 are guarded while the last is explained in a COMMENT instead. Three independent reviews in one wave found the same shape: improvise-mai9 (the helper was tested, the caller that actually rendered was not), improvise-0x9u (preventDefault and the close/dismiss split were both tested, the drawn-vs-seed restore was not, and reverting it shows 18400 where 18,400 belongs on the shipped demo), improvise-o31u (the phone arm was measured, the desktop arm was justified by a mechanism measurement contradicts). The unguarded edit is reliably the one the author understood BEST -- which is why it carries the most careful comment, and why it felt covered: the comment is doing the work the test should, and it reads as evidence because it is the most thoughtful thing on the screen. The check is per-edit, not per-fix: for each distinct edit, name the test that fails when ONLY that edit is reverted. A line with a comment and no answer is the line to inject.').
@@ -388,6 +390,9 @@ doctrine(a_pipe_discards_the_exit_code_you_meant_to_read,'FOURTH occurrence in o
doctrine(a_dev_shell_is_a_third_environment,'.envrc puts nix bash 5.3 on PATH -- the RIGHT INTERPRETER, CI semantics -- and pkgs.mkShell ALSO exports about thirty generically-named lowercase variables the runner does not have: name, out, outputs, system, shell, builder, stdenv, phases, buildInputs, doCheck and more. A script reading one by accident under set -u is SILENT IN THE DEV SHELL AND FATAL IN CI. improvise-f9qf: pr-reviews-test.sh passed 19/19 under macOS bash 3.2 AND 19/19 under the dev shell bash 5.3, while failing 2/19 in CI -- two different reasons for green, one of them the correct interpreter, and unsetting \`name\` alone reproduced CI 2/17 exactly. Note nix shell does NOT export name and nix develop DOES (verified with env -u name as the control), so the dev shell is the one nix entry point that masks this class. The orchestrator own verification of this was itself confounded by inheriting name from a direnv-loaded shell -- it answered \"does name end up set\" where the question is \"does nix shell set it\". CHANGED 2026-09-24 (#287, #297): every CI step now runs inside nix develop .#ci, itself a pkgs.mkShell, so CI exports those variables too and the dev shell no longer masks a CI failure of this shape. The hazard moved rather than vanished: the environment that differs now is a BARE local shell with no direnv, and checkers.sh still scrubs to an allowlist so a suite cannot come to depend on one of them either way.').
doctrine(checkout_ours_or_theirs_drops_the_other_sides_clean_changes,'git checkout --ours or --theirs on a conflicted file takes the WHOLE file from one side, so it silently drops every non-conflicting change the other side made to that file. #245 resolved a ci.yml conflict with #228 by --theirs and lost its own check-harness-lock --allow-network step while its merge message said the step was unchanged (improvise-dndu). No gate saw it: check-ci-graph.py counts suites, not that step, and a message cannot be checked at all. Resolve by hand, then diff the merge against the merge base and against EACH parent -- the question is not whether git combined the texts but whether the result holds both sides.').
doctrine(a_clean_merge_of_one_test_file_can_still_fail_together,'Two branches can each pass CI, merge with no conflict, and fail together. #287 found that on the NixOS runners /usr/bin:/bin holds only env and sh, and gave section 5 of checkers-test.sh a hermetic utilbin PATH; #237, cut earlier, added section 5b still using PATH=...:/usr/bin:/bin. The merge was textually clean and 5b broke every PR after it (#296). A fix to an IDIOM -- a hermetic PATH, a scrubbed environment, a helper every case must call -- is owed by every case in the file, including cases that exist only on another open branch. After a merge, grep the merged file for the old idiom, and run the gates on the merged tree rather than on each side.').
doctrine(history_is_reconciled_only_by_merge,'The user, 2026-09-25: the merge structure of git history is a causal record (improvise-1xfb.1). A merge keeps that record, and a rewrite replaces it with a story. So: (1) the default is a NEW Conventional Commit -- As a general rule, you shuold just add a new conventional commit. Small adjustments can ammend HEAD, but when in doubt don\'t [sic] (improvise-1xfb.2); (2) amend only HEAD, only for a small adjustment, and only while it is unpushed; (3) never reorder, fold or squash commits, pushed or not, and never move them onto another base -- no git rebase and no git reset --soft gh/main; (4) nothing that would need a force push, and no force push; (5) a newer main is taken by re-pinning gh/main and running git merge on that SHA; (6) git cherry-pick -x only for commits that will never reach main as they are -- an abandoned or rebuilt PR, or commits only on a mirror or a local main -- since a cherry-pick drops the causal link. The rewrite is also where the damage came from: twice a git reset --soft gh/main computed a squash against a ref another session had moved and staged a silent revert of a merged PR (#196, improvise-diho; #303, caught by the numstat check). The rule text is .agents/skills/wave/agent-rules.md rule 8.').
doctrine(a_review_names_the_sha_it_reviewed,'A review of a superseded commit is worse than no review: it reads as a pass. So the reviewer names the exact SHA, the author announces every push, and a re-review is of the delta. The author never rewrites history, in review or out of it: new commits and merges only, an amend only to an unpushed HEAD, and nothing that needs a force push (doctrine(history_is_reconciled_only_by_merge); agent-rules rule 8; improvise-at5k). Each fix is therefore a new commit on top of the reviewed SHA, and the delta is exactly what the re-review reads. This matters more now that adversarial agents are the gate a merge waits on -- Copilot\'s request was a SILENT no-op for much of 2026-09 (the mutation returned success and registered no reviewer), which is itself an instance of doctrine(an_advisory_gate_is_not_a_gate).').
doctrine(a_hard_reset_discards_knowledge_written_to_the_working_branch,'The orchestrator wrote doctrines to kb.pl on local main, pushed each as a branch, then ran git reset --hard gh/main to resync -- twice -- and silently discarded six clauses that existed only in the unmerged local commits. Nothing failed: kb_consistent passed on the reduced file, the counts simply read lower, and the loss was found only by grepping for clause names remembered from the session. Three rules follow. Write a fact ONCE, on the branch that will carry it to main, not on a local main. Resync local main by git merge --ff-only gh/main, never by a reset: a reset or force-move that drops commits is forbidden under doctrine(history_is_reconciled_only_by_merge) (improvise-at5k), and the ff-only merge refuses exactly where the reset would have discarded something. And if a branch holding knowledge has lost commits anyway, re-count the predicates and diff clause NAMES against what you believe you wrote -- a total that merely looks plausible is the same non-evidence a passing test count is.').
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.').
@@ -439,6 +444,11 @@ work_done('improvise-nxah',bugfix,'PR #136, merge eefd992. The client rolls back
work_done('improvise-tg27',bugfix,'PR #138, merge 327a76b. header flex-wrap over a breakpoint; main min-width:0; panels capped at 40vw; harness/tests/narrow.spec.js asserts documentElement.scrollWidth <= clientWidth at 360/520/768, so the page own overflow leaves gotcha 48 list of what the harness cannot see. Fault injected by restoring style.css to 22ab58f: 6 of 9 assertions failed, and the two that passed are the two that should. Merge with improvise-nxah verified STRUCTURALLY -- 198 insertions and ZERO deletions across context/, with evidence entries rising 812 to 818 as the independent witness. Found not predicted: the keyboard already reached every clipped column, so what was broken was a FALSE scroll affordance beside a working one.').
work_done('improvise-k844',bugfix,'PR #139, merge 1ae2d2e. importance_ranking sums and divides in f64, narrowing only the bounded share. The injected rival is THE TICKET OWN TITLE -- reassociating to 100.0*(imp/total) in f32 passes the lone-importance and sums-to-100 tests and fails the twin and the guard, 58 passed 2 failed, converting NaN% into a serene 0% for every feature. Scoring bit patterns byte-identical, which is the evidence the fix is confined. context/code/gp.md section 7 corrected in place: it had deferred this bug on a cost that does not exist.').
work_done('improvise-f9qf',tooling,'PR #137, merge a9cad15. scripts/checkers.sh plus scripts/checkers-test.sh (12 cases, each INJECTING rather than asserting). The proof: restoring the multi-assignment local gives 22 passed under the old local gate, 22 passed under direnv, and 2 passed / 20 failed / exit 1 under checkers.sh. The second undetected split is closed -- the four #!/bin/sh and one #!/usr/bin/env sh scripts are now bash, a strict superset needing no porting. Unrun and stated: scripts/build-web-release.sh executes only under dist in build-global-artifacts, which no branch push exercises.').
work_done('improvise-gtn7.1',bugfix,'PR #303, merge bb01cd19. The TUI grid width is one derivation: viewport::grid_width(term_width, side_panel_open), reached through App::grid_width, is what draw.rs::draw_content splits the content row on and what App::cmd_context counts visible_cols on. The keypress path had counted from term_width while the draw gave the grid term_width minus the 32-column side panel, so with any panel open the cursor scrolled behind it. The tile bar had the same shape and now scrolls (first_tile) and draws from one tiles_left(start). Guarded by draw.rs content_tests::moving_right_keeps_the_selected_cell_drawn_beside_any_side_panel and tile_bar the_selected_tile_is_drawn_at_every_width_it_fits_in. Mixed-width columns are still wrong: improvise-gtn7.2.').
work_done('improvise-cgqt',feature,'PR #304, merge 4f38a35e. The import wizard asks what to do with records that write one cell (ResolveDuplicates, NameDuplicateCategory), DuplicatePolicy {Sum, Separate(name), Refuse} with Refuse the default, Collision as data so every front end draws the same rows, and --on-duplicate on the headless CLI.').
work_done('improvise-9bit',bugfix,'PR #304 (commit f00ed3e9), merge 4f38a35e. The collision check counted records with disjoint measures on one tuple as colliding; collisions_among now keeps only records that write a measure another record on the tuple also writes. Rival: the tuple-only rule, which fails only_the_records_that_share_a_cell_are_listed_and_numbered.').
work_done('improvise-4klp',test,'PR #304 (commit f00ed3e9), merge 4f38a35e. the_name_check_covers_categories_derived_from_dates: a date-derived category name such as when_Year is refused as the occurrence category name. Green on arrival; its evidence is the reviewer rival that drops date-derived names from the taken names.').
work_done('improvise-felo.1',feature,'PR #307, merge 1719f2cf. MAX_CATEGORIES raised from 12 to 24 in improvise-core model/types.rs, pub and re-exported as improvise_core::model::MAX_CATEGORIES; the import wizard private copy is deleted and it reads the model constant. Fill-to-the-cap tests derive the number from the constant. crates/improvise-core/examples/category_width_bench.rs measures 12 against 24; the coupling inventory is context/code/core.md.').
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.').
@@ -560,6 +570,10 @@ decision('improvise-i5z6',compare_the_slowest_binary_to_the_next_slowest,'The ra
decision('pr-landing-2026-09-24',check_base_ref_before_merging,'Before merging ANY pull request, its baseRefName must be main. A stacked PR whose base PR has already merged still targets the stale base branch, and merging it lands the change there, not in main, while the merge reads as done. On 2026-09-24 fifteen approved stacked PRs in #255 to #286 merged into a base whose own PR had already merged; recovery took seven landing PRs (#289 to #295), each from a stack tip against main, whose titles name exactly those fifteen. Four more in that range (#259, #263, #272, #273) merged into a non-main base whose PR was still open and reached main with it, which is stacking working as intended. The repository reported delete_branch_on_merge true when measured that day, and the stale bases were still on GitHub afterwards, so that setting is not the guard: the check is. Retarget with gh pr edit <n> --base main once the base PR has landed, then read the diff again. bd memory stacked-pr-merges-into-stale-base (which says sixteen).').
decision('improvise-3uow',ci_takes_its_tools_from_the_ci_dev_shell,'User rulings 2026-09-24. CI runs on self-hosted NixOS microVM runners, and every run: step runs inside nix develop .#ci through the workflow defaults.run.shell; a uses: step (an action) does not go through a shell at all. A missing tool goes into the .#ci shell in flake.nix, never into a per-step nix shell wrapper. No hard-coded /bin or /usr/bin paths, since those hold only env and sh there. No bash-version floors, no tool-version probes: scripts and tests must not care which bash or standard utilities are present, so checkers.sh lost its bash 5.1 pin and the dev-shell toolchain-pin step left ci.yml (#297). Playwright browsers come from nix via PLAYWRIGHT_BROWSERS_PATH, so there is no browser install step; npm ci stays for @playwright/test. A test that fakes an environment builds it on purpose (a utilbin of linked tools) rather than assuming the runner holds anything. bd memory ci-is-version-agnostic.').
decision('improvise-sboz',a_formula_edit_maps_display_names_to_ids_when_the_editor_opens,'ADR 007 clause 12 (the ADR own reading, accepted with revision 8 on PR #210). The formula panel shows display names (answer 19); the display-name-to-id mapping for every name the panel showed in that formula, the target included, is captured when the formula editor OPENS, and the commit resolves the edited text through it -- the capture-at-open rule OpenedCell already follows for cells (decision improvise-8xq0). Mapping at commit fails both ways under a concurrent rename: a name that no longer resolves refuses the edit (contradicting clause 6), and a name now owned by something else silently rebinds it (the glitch answer 3 exists to prevent). A name typed that was not captured is resolved at commit and refused, naming it, if it does not resolve; a captured id gone at commit is Rejection::Gone (clause 7); was is still compared against the stored id-form text, so a concurrent replace is still seen. Required regression test: open on Profit = Revenue - Cost, rename Revenue to Sales and another item to Revenue from a second client, commit unedited; it must still name the original id and not be refused -- failing under commit-time resolution and under refuse-on-rename.').
decision('improvise-gtn7.1',scroll_measured_where_drawn,'A scroll offset is chosen against the same width the widget is drawn in, from one function both sides call, never from two derivations that agree by coincidence. For the grid that is viewport::grid_width; App::side_panel_open is the one answer to whether a panel took its column, so a panel that did not count toward the width would not be drawn either. For the tile bar it is tiles_left(start), where first_tile measures from and where the draw begins. context/code/tui.md, The grid width is one answer.').
decision('improvise-cgqt',import_collisions_are_asked_not_defaulted,'PR #304, merge 4f38a35e. When records would write the same cell, the import wizard asks: ResolveDuplicates offers sum, keep separate under a new category, or go back, and appears only when ImportPipeline::collisions() is non-empty, after the field and date steps and before DefineFormulas. Silently summing was improvise-2cw9 fix for last-row-wins, and it too was a default the user never chose. The headless --no-wizard import takes the answer as --on-duplicate refuse|sum|separate. context/code/formula-and-io.md, Colliding records are a question, not a default.').
decision('improvise-cgqt',occurrence_category_goes_on_axis_none,'Under DuplicatePolicy::Separate the build adds the occurrence category with items 1..=deepest collision and sets it to Axis::None in the active view, so the default grid still shows each tuple total and nothing new is pivoted. A record that collides with nothing is occurrence 1 (improvise-cgqt, improvise-9bit).').
decision('improvise-felo.1',category_cap_has_one_home,'The regular-category cap is improvise_core::model::MAX_CATEGORIES and nothing else. Until felo.1 the import wizard kept a private const of its own, so raising the model number alone would have left the wizard refusing a 13th category the model accepts -- a derivation stated twice that agreed only by coincidence. The only place the literal 24 is spelled is the test add_category_max_limit; every other test that fills to the cap derives it. No recorded reason for 12 was found; the user raised it on 2026-09-25 (12 categories is feeling small, we should increase to 24).').
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').
@@ -718,6 +732,8 @@ kb_issue_found(check_evidence_cannot_see_a_templated_test_name,'scripts/check-co
kb_issue_found(k844_description_was_verbatim_e8no_body,'improvise-k844 stored description was a verbatim copy of improvise-e8no body -- the wide-numeric-category budget -- contradicting its own title. Confirmed by diffing the two DESCRIPTION blocks: identical. The agent worked the TITLE, which was right, and the defect had one good consequence: reading e8no text while working k844 is what made it check whether e8no was done, and e8no turned out to be STALE-OPEN with both its guard tests passing. A ticket carrying the wrong body is worse than an empty one, because an agent that trusts it works the wrong problem while believing it has a specification.').
kb_issue_found(the_old_checker_gate_overrode_every_shebang,'The gate in the orchestrator brief -- \`for t in scripts/*-test.sh; do sh \"$t\"; done\` -- OVERRIDES EVERY SHEBANG with /bin/sh, so on macOS it ran every suite under bash 3.2 in sh-mode regardless of what the file declared. The gate was the disease it was meant to catch. Seven further sites ran a repo script through an explicit \`sh\`, and ONE OF THEM IS dist-workspace.toml, which a first-line shebang gate STRUCTURALLY CANNOT SEE: the shebang rule closes DECLARATIONS and never INVOCATIONS, and that one was found by grepping for the CALL after fixing the declaration, not by the gate going green. Also: scripts/node-test.sh matches *-test.sh and is a test RUNNER, so a naive glob ran it with no arguments and it printed its usage INSIDE A GREEN GATE -- the rule is now that a suite is X-test.sh where X or X.sh exists beside it.').
kb_issue_found(copilot_stopped_reviewing_this_repo,'Measured 2026-09-20: PRs #12, #21 and #27 carry 2, 8 and 4 copilot-pull-request-reviewer[bot] reviews; #61, #66, #68 and #70 carry none. Both request paths reported success and registered nothing -- the GraphQL requestReviews mutation with botIds BOT_kgDOCnlnWA returned clientMutationId null, and POST /pulls/<n>/requested_reviewers with copilot-pull-request-reviewer[bot] returned 200 with requested_reviewers empty -- and no review_requested timeline event appeared. Consequence for the process at the time: AGENTS.md said \"merge only once Copilot passes\", which is an instruction to wait forever on a review that was never requested, since an ABSENT review and an UNREQUESTED one look identical from outside. That is doctrine(review_is_requested_not_inherited) seen from the requesters side. THE LAPSE ENDED: on 2026-09-24 Copilot posted inline findings on #221, #251, #274, #295 and #298, so this issue is history, not the present. AGENTS.md now says its findings are read and answered like Codex, through scripts/pr-reviews because most arrive as inline comments that gh pr view --json reviews does not show, and still that nobody waits on it: the review that gates a merge is an adversarial agent given the head SHA.').
kb_issue_found(tui_grid_width_bug_latent_since_b5418f2e,'improvise-gtn7.1 looked like a regression and was not. compute_visible_cols took term_width from b5418f2e (2026-04-06), so the keypress path was always wrong with a side panel open; nothing opened one at launch until 50e4a372 (2026-09-20) put category_panel_open in the shared ViewState::default(), and from then every TUI session started in the broken configuration. A default changed in a shared type is a new configuration for every consumer, and a latent bug in that configuration surfaces as if the default change caused it. The tile bar offset had been wrong since 4686f470, when it first scrolled.').
kb_issue_found(record_row_group_is_quadratic_in_records,'RecordRow::group (improvise-core view/layout.rs) finds each cell row with rows.iter().position, a linear scan, so the records-mode build is O(cells x rows). Found by the felo.1 width benchmark: the records build grows about 4x when the records double, at 12 and at 24 categories alike, so it is not category coupling. Filed as improvise-sk32.').
functional(invariant/2).
functional(location/3).
functional(doctrine/2).