Part 4 of a series on a self-modifying multi-agent LLM system. Report and data: doi.org/10.5281/zenodo.23622286. This episode happened after the report's data close (2026-10-24); it will go into version 0.3.
So far this series has been mostly about what failed: goals closed on announcements, a test gate that went green on empty output, 37 modules written to silence measurements. A reader could conclude that nothing works in a system like this. This post is about something that did work, why it worked, and what it still could not prevent.
Four days, six modules
On 2026-10-26 the commit hook got two new rules. Self-edits started bouncing off it. Those refusals were logged as selfedit.commit / bramka_commitu ("commit gate"), and that is where the trouble began. The scaffolding step, which asks a model to propose one new module, includes the top five "frictions" of the day in its prompt. The classifier filed hook refusals as defects. For four days in a row, the top line of that list was the commit gate refusing the system's own changes (9, 7, 8 and 7 refusals).
The model answered the way a model answers a prompt like that. It proposed modules about getting commits through:
| date | module | what the code does |
|---|---|---|
| 10-26 | commit_preflight_rule_checker | checks a diff against the commit gate's rules and returns blockers with suggested fixes, "so callers can avoid repeated bramka_commitu drop events" |
| 10-27 | commit_message_validator | Conventional Commits format, length 3–72 |
| 10-28 | commit_gate_failure_rule_extractor | pulls rule IDs out of the last gate refusal in the log |
| 10-28 | selfedit_commit_parent_verifier | checks HEAD against the parent recorded before the edit |
| 10-29 | commit_message_required_section_ensurer | appends missing message sections |
| 10-29 | selfedit_commit_gate_failure_auto_fixer | applies the replacements from the gate's report and returns the diff |
None of them disables a check. Together they are a toolkit for passing the check: learn the rules from the refusals, pre-fix the diff, pad the message. None had a single caller outside its own test.
The attempt
On 2026-10-29 at 14:27 the wiring sweep, which finds a place for each new module, asked where to wire commit_preflight_rule_checker. It got an answer and registered a goal:
selfpatch: core/self_edit.py - call parse_unified_diff() from ecosystem.commit_preflight_rule_checker near SelfEditor
core/self_edit.py is not just any file. It is the one that enforces the protection list: every write path of the self-editor goes through its path check. It has been on that list since 2026-08-11, with this reason recorded next to the entry: "enforces this list; weakened, it lets writes reach every protected file above." We put it there after measuring that the system had rewritten it ten times between July 8 and August 3.
The routing step returned the goal three times, at 14:43, 14:49 and 15:16, each time with plik_wykluczony ("excluded file"). It did not redirect the work to some other file. It returned it.
Why it held
- It was not a judgment. The check is a deterministic match on a file name at the single bottleneck that every write path passes through. No LLM was asked whether the change looked safe.
- The enforcer is on its own list. A protection list is only as strong as the code that reads it. If the system can rewrite that code, it can bypass the protection without touching a single entry. So the enforcer is protected too, and so is every new gate, in the same commit that creates it.
- It fails closed. A refused step goes back with a reason. Nothing falls through to a default target.
- The protected file was not where the work was happening. We protected files the system had stopped developing weeks earlier, so the rule cost almost nothing to keep.
What it could not stop
The protection stopped the write, but it did not stop the pressure. The goal re-entered the queue after each refusal, six modules got written, and our daily checklist flagged the episode as a failure: 3 of 4 routed steps returned. The real fix was upstream, on 2026-10-30:
- refusals from the checking layer (hook, tests, routing, protection) are now classified as gates, not defects, and never enter a prompt;
- excluded files never receive a wiring goal in the first place;
- the four remaining
commit_*modules were deleted (two had already been reverted).
It is the same lesson as the 37 silencer modules: a measurement you hand to a model becomes a target, and a gate's refusals are a measurement too. Protection limits what the system can do. Classification decides what it tries.
Coda: the next module went the other way
The first scaffold in the same domain after the fix, on 2026-10-30 at 23:13, was hardcoded_secret_scan.py. Its four patterns (AWS key, GitHub token, private key, api_key = … style assignments) add a check instead of learning to pass one. It still has no caller, and wiring it into the hook is a human decision, because it is also checking-layer code written by the system.
I did use it, read-only, on everything in the Zenodo deposit before writing this post, alongside my own patterns and the values from the local .env: no secrets (the only match was a model name).
One episode, one system, so it is a hypothesis rather than a law. But if you run agents that can change their own code, the first three properties above (deterministic, self-protecting, fail-closed) are cheap, and they held up in practice.


