kb: a write cannot disarm its own gate, and cannot silently lose another's

Second review pass on PR #68. All three findings real; the first two reproduced
by injection before fixing.

The candidate is validated by CONSULTING it, so the thing being checked got to
influence the check. Three ways through, all demonstrated:

- `:- halt(0).` as a delta -- fixed last round by refusing directives
- REMOVING kb_consistent: it then throws existence_error, and tpl exits 0 when a
  goal throws before reaching halt, so the gate passed by not existing. The
  error was printed and the tool still reported success.
- REPLACING it with the fact `kb_consistent.`, which passes forever after.

CHECK_GOAL now catches, so a throw exits non-zero, and writes touching the gate
predicates are refused unless KB_ALLOW_GATE makes it deliberate. That list is
hand-maintained and a new gate predicate is unprotected until named -- stated
rather than hidden.

Writes were read/validate/mv with nothing in between, so two agents -- ordinary
here, waves run in parallel -- would both read the same file, both validate, and
the later mv would discard the earlier addition. The lost-update class this tool
exists to remove, reintroduced in the tool. Closed by stamping content at read
time and refusing to commit if it moved, plus an mkdir lock. The stamp is the
part that matters; the lock only makes the collision rare.

AGENTS.md's top-level instruction still said to save facts through the MCP. The
wave section had been fixed; the authoritative line had not.

kb-test.sh is 17 cases. The race is deterministic via KB_TEST_RACE.

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-20 16:11:24 -07:00
parent 7fd57c06b3
commit eaaf9d935a
5 changed files with 132 additions and 9 deletions
+2
View File
@@ -214,6 +214,8 @@ doctrine(a_goal_runner_exits_zero_unless_halt_is_reached,'trealla -g runs a goal
doctrine(kb_is_maintained_by_scripts_kb,'prolog-sessions/kb.pl is written by scripts/kb, which reads clauses as DATA and never consults them, so adding a clause costs the clause. This doctrine was named kb_is_maintained_through_the_mcp and the NAME was the claim that went false -- the same repair opfs_is_run_but_bindings_is_not needed. The MCP was retired because loadProgram REPLACES a whole predicate: adding one clause meant re-sending every existing clause of it through an agent context window, measured at ~113KB read and ~113KB written to deliver ~25KB, and a short payload silently drops the rest. Three residues of it remain true and are why nobody should go back: saveSession re-adds eight host_rpc shims (AGENTS.md enumerates seven -- js_eval_json is missing, so a grep built from that list is one case short), saveSession reserializes the whole file, and assertz raises permission_error(modify,static_procedure) against a consulted predicate so there is no incremental route through it. Reading is unaffected: loadSession at session start is still how a session gets the model in hand. See context/code/kb-tool.md.').
doctrine(documentation_is_operational_and_history_lives_in_the_kb,'User ruling 2026-09-20: documentation should not spend much on history -- that is bloat. Prose docs (AGENTS.md, skills, context/code) say what to DO, with only the one line that keeps a rule from looking arbitrary. The worked examples, the dates, the war stories and the \"we tried X and it failed this way\" belong in doctrine/2 and decision/3 text, which is what those predicates are for and where a reader goes deliberately rather than while looking for a command. The test to apply to a paragraph: would someone following this document need it to act correctly? If it only explains how the rule was discovered, it is a clause, not a doc.').
doctrine(an_agent_posting_to_github_says_so,'User instruction 2026-09-20: anything an agent posts to GitHub goes out under the repository owner account, so every PR comment, review and issue body opens with a line naming the model and that it is an agent posting in his session. A PR body 🤖 Generated with Claude Code footer already does this; comments carry nothing unless it is added, and an unattributed comment reads as the owner own words to every reader -- including reviewers deciding how much weight to give it. The same applies to anything else published outward under his identity.').
doctrine(a_write_must_not_be_able_to_disarm_its_own_gate,'scripts/kb validates a candidate by CONSULTING it and running kb_consistent, so the thing being checked gets to influence the check. Three ways that was exploitable, each demonstrated on PR #68 before it was closed: a delta of \`:- halt(0).\` -- a directive is callable, so a callable test lets it through -- exited the check with success before kb_consistent ran; REMOVING kb_consistent made it throw existence_error, and tpl exits 0 when a goal throws before reaching halt, so the gate passed by not existing; and REPLACING it with the fact kb_consistent. passes forever after. Fixes: refuse directives and queries by shape, catch in the validation goal so a throw exits non-zero, and refuse writes touching the gate predicates unless KB_ALLOW_GATE makes it deliberate. The general form: when a checker consults its own input, ask what the input can do to the checker, not only what the checker says about the input. The gate predicate list is hand-maintained, which is its residual weakness -- a new one is unprotected until named.').
doctrine(a_read_validate_write_sequence_is_a_lost_update,'scripts/kb read the knowledge base, merged, validated and moved the result into place, with nothing between the read and the move. Two agents doing that concurrently -- which is ordinary here, waves run agents in parallel -- both read the same file, both validate, and the later move silently discards the earlier addition. Exactly the class this tool was built to remove, reintroduced one level up in the tool itself. Closed by stamping the file content at read time and refusing to commit if it changed, plus an mkdir lock (flock is absent on macOS) so the collision is rare rather than merely loud. The stamp is the part that matters: a lock prevents the common case, a compare-and-swap is what makes an uncommon one impossible to lose silently.').
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.').