docs(kb): the command lane's doctrine, and a second vector for the base trap
CI / Build all bundles (push) Has been cancelled
CI / Test and lint (host) (push) Has been cancelled
CI / wasm32 check (browser bundle crates) (push) Has been cancelled
CI / Browser JS unit tests (node --test) (push) Has been cancelled
CI / Knowledge base consistency (push) Has been cancelled
CI / Lint the harness crate (push) Has been cancelled
CI / Browser harness (OPFS contract + shell) (push) Has been cancelled
CI / Deploy to GitHub Pages (push) Has been cancelled

From PRs #144/#148/#151. `AGENTS.md`'s remote note gains the worktree-base
vector: a branch cut from the orchestrator's local `main` inherits whatever
that checkout merged but did not push, and nothing in the branch says so —
which is how #148's diff came to name the three orchestrator-only files and
read as a rule violation rather than a base problem.

Four facts. A fold test must fold the batch the producer really emits — a
`Binding::Sequence` flattens into one vec, and a per-step batching cannot see
intra-batch order at all. A type name is not a field, measured: one 14-site
rival left 14 of 16 assertions passing, while an assertion on a *fieldless*
effect is complete rather than smoke. A registry with two arms demotes the
weaker caller, and neither direction is safe by default. And a clean text
merge can delete an evidence entry that no gate checks.

Two gate traps: `--check-evidence` prints the comment cap FIRST, so reading it
through `tail -1` drops the half that was always there; and a compound command
refused by the worktree shell guard runs NEITHER part.

kb_consistent: TRUE, 798 facts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
This commit is contained in:
Edward Langley
2026-09-22 19:34:07 -07:00
parent b3490da54f
commit b1a989d8cf
3 changed files with 18 additions and 10 deletions
+11 -9
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -149,7 +149,7 @@ where four unmerged commits — including another PR's whole diff — were pulle
into a branch based on `gh/main` and had to be rebuilt by cherry-picking.
ChatGPT Codex caught it; nothing in CI could, because the result compiles and
passes. The tell is a PR diff naming files the ticket has no reason to touch,
so **read your own PR's file list before asking for review**. Note the window
so **read your own PR's file list before asking for review** — and diff against `gh/main...<branch>`, never the local equivalent. **The second vector is the worktree base**: a branch cut from the orchestrator's *local* `main` inherits whatever that checkout has merged but not yet pushed, and nothing in the branch says so. That hit PR #148 with another PR's whole diff — including `.beads/`, `kb.pl` and `context/repo-map.md`, the three files agents are forbidden to touch, so the review read as a rule violation rather than a base problem. Note the window
is transient — the two remotes are equal immediately after the orchestrator
syncs — which makes it worse, not better: the hazard is absent exactly when
you would think to check for it.
+6
View File
@@ -370,6 +370,11 @@ doctrine(an_injected_fault_is_not_reverted_until_the_full_suite_is_rerun,'An age
doctrine(a_fixture_that_names_its_own_precondition_must_read_it_back,'A test asserting that a session was shown something must READ THE VIEW OFF THE SESSION, not hard-code the name. A first version derived its grid from grid_in(&app,\"Other\",..), so the session, the switch and apply_checked could ALL BE DELETED and every assertion still passed -- it proved a grid built for a NAME while its name claimed a grid a SESSION WAS SHOWN. The repair reads sessions.get(id).view().active_view, asserts it MOVED, and asserts it DIFFERS from the host: the assert_ne! is what pins the premise, since assert_eq!(seen,\"Other\") alone passes if the host moved too. Found by review inside a PR whose whole subject was a scoping distinction -- the characteristic defect appearing in the change that documents it.').
doctrine(a_mirror_remote_named_main_is_not_the_pr_base,'AGENTS.md makes origin and gitea mirrors that take main directly while GitHub gets branches and PRs, so ORIGIN/MAIN RUNS AHEAD OF GH/MAIN. An agent running the habitual git merge origin/main therefore pulls UNMERGED work into a branch based on gh/main -- on PR #145 that was four commits including another PR whole diff. The two remotes are both spelled main and nothing about the command looks wrong; nothing in CI can see it, because the result compiles and passes. Caught by ChatGPT Codex; repaired by cherry-picking onto gh/main. THE RULE: a branch destined for a GitHub PR merges gh/main, never origin/main, and you read your own PR file list before asking for review -- a diff naming files the ticket has no reason to touch is the tell. THE WINDOW IS TRANSIENT, equal immediately after an orchestrator sync, which makes it worse rather than better: the hazard is absent exactly when you would think to check.').
doctrine(a_fixture_width_is_part_of_what_a_test_can_see,'renders_each_tile_with_its_axis_label renders at 80 COLUMNS, where the tile bar hint does not fit and is NEVER DRAWN -- so the one test pinning that widget output was structurally incapable of seeing either the false hint or its removal (improvise-rr8n). A false key literal survived there for that reason alone. The same shape as a one-category fixture or a helper that fabricates the items the code under test needs: the fixture, not the assertion, is what decided the question. When a test pins RENDERED output, the width, height and terminal size are inputs to be chosen deliberately and stated, and a claim about something that only appears at a width the fixture never uses is not tested at all.').
doctrine(a_fold_test_must_fold_the_batch_the_producer_really_emits,'A Binding::Sequence FLATTENS into one effect vec (gotcha 21), so a test that applies each command as its OWN apply_effects batch cannot see intra-batch order at all: a reversed-fold rival passes against it, because each single-Move batch reverses to the same cursor. The first replacement for improvise-gjat had exactly that shape and the rival did not fire. So the batching in the test must match the batching the PRODUCER emits, not whatever is convenient to write -- otherwise the test is folding a different fold and its name is wrong. Generalises doctrine(fold_tests_must_test_order_not_the_axiom): that one says do not assert the left-fold axiom, this one says make sure you are folding the right batch.').
doctrine(a_type_name_is_not_a_field,'An assertion naming a field-carrying effect type and nothing else survives a WRONG CONSTANT FIELD. Measured on improvise-lm5e: one patch made 14 command sites emit the right effect type with a wrong field; under the old assertions that was 525 passed / 6 failed, under the new ones 510 / 21 -- so 14 of 16 such tests passed the rival, and of the 6 that failed only ONE was in the list, the other five living in ui/app.rs and ui/reduce.rs. The command layer never checked a field at all. THE CONVERSE IS ALSO TRUE AND MUST NOT BE OVER-CORRECTED: an assertion on a FIELDLESS effect (Quit, Save, RebuildLayout, TransposeAxes, AbortChain, TogglePruneEmpty) is COMPLETE, not smoke -- there the type name IS the whole claim.').
doctrine(a_clean_text_merge_can_delete_an_evidence_entry_that_no_gate_checks,'Two agents appending disjoint sections to context/test-evidence/*.md at the same point conflict, and the resolution LOOKS right whichever side is dropped. --check-evidence CANNOT CATCH IT: it asserts every heading names a running test and that no test is named twice, never that every entry SURVIVED. So a resolution must be verified line-by-line against BOTH parents -- every non-marker line of :2 and :3 present in the result -- and not eyeballed. Same family as a-clean-merge-can-delete-your-tests, one level up: there the loss was a test, here it is the recorded reason the test exists, and a lost reason is invisible forever because nothing regenerates it.').
doctrine(an_additive_gate_read_through_tail_is_not_a_gate,'scripts/check-comment-runs.py --check-evidence is ADDITIVE since improvise-ijql -- every invocation that can exit 0 also answers the comment cap -- and it prints TWO lines, the cap FIRST and the evidence summary second. So reading it through tail -1 keeps the half you added the flag for and silently drops the half that was always there. Measured: an agent ran it that way, its local run HAD already found a three-line comment, and CI caught it instead. The general shape is worse than this one instance: a gate that grows a second responsibility keeps the same command line, so every pipeline written against the old single-line output degrades SILENTLY into checking less. Never pipe a gate through head or tail, and when adding an output line to a checker, consider that callers may be filtering.').
doctrine(a_compound_command_refused_by_the_shell_guard_runs_none_of_its_parts,'In a worktree-isolated agent the shell guard refuses a whole compound command, so \"cp -f <restore> && cat >> <evidence>\" ran NEITHER part -- leaving an injected rival in the working tree, which was then carried through a git merge autostash into a branch. The full suite caught it at 1713 passed / 1 failed. Two things follow. A refusal is not a partial execution and must not be read as one, so re-check the state a refused command was meant to produce rather than assuming the first half landed. And this is the concrete argument for running EVERY gate after EVERY change rather than once after the last one: the window between injecting a rival and reverting it is exactly where a refused restore hides.').
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.').
@@ -523,6 +528,7 @@ decision('improvise-6uva',script_replay_lost_its_context_at_35946af,'improvise s
decision('improvise-gd28',browser_drill_blocked_by_surrogate_scope_not_layer_2,'The ticket recorded drill as blocked on ADR 001 layer 2 because ApplyAndClearDrill is session-local and unaddressable. That reasoning is about the COMMIT path. Entering a drill needs no commit, and CreateView/SetAxis/SetPageSelection are already unaddressed ModelCommands that NAME THEIR VIEW. What a browser cannot supply is the OPERAND -- CellId::Aggregate(AggregateHash), an opaque u128, which is ADR 002 working as designed. The real gap is one level down from where it was looked for: THE MUTATION AND ITS OPERAND HAVE DIFFERENT SCOPES. The mutation configures the shared _Drill and is addressed to nobody; the operand exists only inside one session layout. So the fix is not a new mechanism but the view:String convention every other view-scoped variant already follows, resolved against a layout derived for that name -- no SessionId needed. fdk1 is also not needed: edit_or_drill declines records-mode editing OUT LOUD before reaching a cell, so a browser cannot edit inside a drill grid at all and no divergent staging scheme is reachable.').
decision('improvise-rr8n',keep_the_table_derived_from_what_it_describes,'The ticket asked to rewrite the tile-bar hint literal and SUPPRESS the derived status line in TileSelect. The implementing agent did the opposite and the orchestrator ACCEPTED it: the derived status line is a STRICT SUPERSET of the literal (Space, h/l, Tab appear only there) and cannot go stale, so suppressing it would delete information and keep the table that had just rotted. Verified before accepting -- the literal read \"Ctrl+arrows to move tiles\" while those keys are bound to enter-tile-select, so it was already false. Both tile-bar literals deleted; Normal derived line already says T:tiles. The import wizard keeps the ticket ORIGINAL answer, and the asymmetry is the point: ITS status line is derived from the MODE keymap while the real keys are per-step inside HandleWizardKey, so there the derived table is the one that lies. RULE: keep whichever table is derived from the thing it describes. COROLLARY: every_hint_key_resolves_in_its_mode CANNOT SEE A STRING LITERAL IN A WIDGET, the same blind spot improvise-wk18 names for help.rs, so a hand-written key literal is invisible to every hint guard in the repo.').
decision('improvise-t14d',an_affordance_outranks_a_decoration,'draw.rs::fill_line takes a Keeps parameter rather than a global rule, because the three rows sharing a composed line genuinely disagree about which side yields. The title bar keeps the RIGHT -- the ?:help/:q escape hatch outranks an unbounded document name -- while minibuffer_line and draw_status keep the LEFT, which is what they already did, since right-priority there would let a long status message eat the text the user is editing. So the ticket \"give the right segment priority\" was correct ONLY for the title bar. Second fault, and the ticket framed it wrongly: padding by str::len mis-padded EVERY document including pure-ASCII ones, because the separator dot is in draw_title own format string at two bytes and one cell -- not a non-ASCII-NAME problem as filed. Widths now go through ui::text::truncate.').
decision('improvise-6uva',a_registry_with_two_arms_demotes_the_weaker_caller,'CmdRegistry has three registration arms and NOTHING TYPE-CHECKS WHICH ARM A CALLER DESERVES. Three instances surfaced in one fix, and they do not point the same way, which is the part to carry. (1) The pure arm demoted every Move: parse built CursorState::default() so a script cursor reset instead of moving. (2) register_pure two arms DISAGREED WITH EACH OTHER -- interactive refused empty args, pure accepted them -- so \"w\" parsed one way and refused the other. (3) Found by ChatGPT Codex: the interactive arms of clear-cell/yank/paste/drill-into-cell took |_args, ctx| and DISCARDED EXPLICIT COORDINATES, so after the fix \"clear-cell Region/East\" cleared the origin. HERE THE WEAKER ARM WAS THE ONE THAT HAD THE CONTEXT, which is why \"always prefer the interactive arm\" is not the lesson. Make the arms agree, or make the difference the point; resolved for the cell-key commands by key_from_args_or_cursor -- explicit coordinates win, cursor is the fallback.').
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').