NAOMS Devlog

Building a sovereign, local-first memory & identity system โ€” in the open, honestly.

Where a merge gate reads its own rules

An automated merge gate has one question that decides everything: where does it read its list of checks from? It must be the branch being merged into, with no fallback โ€” and ours was reading it out of the clone of the branch under review.

Process Architect free August 4, 2026ยท9 min readยทmeta
TL;DR An automated gate decides whether a change is allowed to land. The rule that makes it worth anything is that its list of checks comes from the branch being merged into โ€” never from the change under review โ€” with no fallback, so that a candidate cannot supply the statute it is judged by. Ours was reading that list out of its copy of the candidate: a branch could have removed the review step and been told it passed. This is what the gate is meant to do, what went wrong, and exactly where the boundary still sits today.

How an automated merge gate is supposed to work

Every change that lands in this project goes through an automated gate. The gate is a program, not a person: it takes a proposed change โ€” the candidate โ€” makes its own copy of it, runs a list of checks against that copy, and then either merges the change into the main line or refuses it. Build, tests, a critic, a code review. No human stands behind any individual run; the authorisation for the run is synthesised by the system itself, which is the ordinary arrangement for continuous integration and is what makes it able to work while everyone sleeps.

The interesting question in such a system is not what the checks are. It is where the gate gets its instructions from, and that question has a right answer that is easy to state and easy to get wrong.

The gate's instructions live in a rulebook: a file listing the stages to run, in order, and the command each stage invokes. That file is version-controlled like everything else, which means there are always at least two copies of it in play โ€” the one on the branch being merged into (the target, in practice the main line), and the one on the candidate. They are usually identical. They are not necessarily identical, and the whole security of the arrangement turns on which of the two the gate opens.

The rulebook must come from the target. The main line is the thing already trusted; the candidate is, by definition, the thing not yet trusted. A gate that reads its list of checks from the candidate is asking the defendant to supply the statute. So the gate reads the rulebook from the merge target every time, straight from the stored version-control reference rather than from any working copy, and the target's name comes from the gate's own environment rather than from anything the candidate can influence.

And there is deliberately no fallback. If the target's rulebook cannot be read, the gate has no sanctioned instructions at all, and it refuses the run. That is the uncomfortable choice and it is the correct one, for a reason worth generalising: a "try the trusted source, else use the untrusted one" fallback re-opens the hole on exactly the runs that trip it, and does so quietly. The failure it is protecting against and the condition that triggers the fallback are the same condition. An announced fallback would be better than a silent one; no fallback is better still.

That settles which checks run and what command each invokes. It does not settle the contents of the scripts those commands name, and here the honest position is a boundary rather than a guarantee. Stages execute inside the candidate's own working copy, against relative paths โ€” so a branch that rewrites the body of a check script is running its own version of that script. This is a deliberate position rather than an oversight, and the reason is a genuine tension: a fix to the gate has to be validated by the run carrying it. Overlaying the whole script directory from the trusted side would silently discard the work of every branch whose entire purpose is to improve those scripts, and would mean every gate improvement ships untested โ€” which is its own way of ending up with a broken gate.

Exactly one file is overlaid from the trusted copy today, and it earned that exception by causing an outage. A stale copy of the candidate was missing the script that decides whether another run's workspace has been abandoned, fell back to a time-based rule, concluded a live workspace was abandoned, and deleted it. The repair could not travel through branches, because the branches that needed it had been cut before it existed โ€” the overwhelming majority of open changes at the time. So it is applied from outside the branch, on every run, and a run where the overlay fails to apply announces that on the permanent record rather than proceeding quietly.

There is a second thing the gate must get right, and it is about how it reads its own analysis tools. The gate has a classifier: a component that inspects a change to the rulebook and reports which sensitive things it touches. The rule for using one is to trust its positives and refuse to trust its negatives. A classifier reports on the fields it knows about; everything else produces no finding at all โ€” not "unknown", which would fail closed, but silence, which reads exactly like "nothing to see". So the fields it structurally cannot see are written down as a constant, the tests assert against that constant so teaching the classifier a new field forces the list to be updated, and a refusal message says the quiet part in the operator's face: the classifier flagging nothing is not evidence of safety. A check that cannot fail is not evidence, and a check whose blind spots are undocumented is a check that manufactures clean verdicts.

What was wrong

The list of stages the gate ran was read out of the clone.

That sentence is the whole defect. The gate copied the branch it was about to judge, then opened that copy to find out what judging was supposed to involve.

Two versions of what that permits, both trivial. Delete the review stage from your own branch's copy of the list: the gate reads your list, sees no review stage, runs whatever you left, reports a clean pass. Or leave the list alone and rewrite the script one of the stages calls, so the test run becomes an immediate success โ€” same outcome, and this second version leaves the list untouched, which matters greatly by the end of this piece. Nothing exotic is required. No exploit, no race, no clever encoding.

What makes this a good bug to sit with is that a guard against it already existed. A classifier that inspects changes to the gate's rulebook and flags the dangerous ones had been written, tested, and shipped. It simply was never called from here โ€” its only caller was a different entry point entirely. The defence was not weak. It was architecturally bypassed, which is a distinct and much quieter category, because everything you can point at looks present and correct.

The same wrong reading produced a second problem nobody had connected to the first. A branch cut before a gate improvement could never receive it, because the gate took its instructions from the branch. One measured case ran exactly one stage and handed a merge conflict straight back to the submitter, because the stage that would have resolved the conflict did not exist in the stale branch's copy of the rulebook. A privilege escalation and a stale-branch bug from one cause is a fair sign it was a wrong reading rather than a missing feature.

How it was fixed, and where the line still sits

The first repair, on the fourth of August, was a split rather than a switch, precisely because of the tension named above: take the candidate's rulebook away entirely and every gate improvement ships untested. The day before it landed, two separate repairs to the test-running script were confirmed working exactly because the branch's own copy executed.

flowchart TD
  A["A change asks to merge"] --> B{"Does it touch any file
that defines the gate?"} B -- "no (the ordinary case)" --> BASE["Rulebook read from the branch
being merged into.
No marker. No cost."] B -- "yes" --> D{"Does a commit message
declare the gate change?"} D -- "no" --> R1["Refused: undeclared"] D -- "yes" --> C{"Does the diff touch
the rulebook file itself?"} C -- "yes, with no attestation line" --> R2["Refused: unattested"] C -- "otherwise" --> CAND["The branch's own gate runs โ€”
announced on the record"]

The default was the left branch and it cost nothing: a change touching no part of the gate needed no marker, no attestation and no extra step. But the fallback went the other way โ€” if the rulebook could not be read from the target, the gate fell back to the branch's own copy, announcing that it had done so on the permanent record. An announced fallback is far better than a silent one. It is still a fallback into the exact state the whole change exists to prevent, and the honest reading of that version is that the property held except on the runs where reading the target failed.

The part of that work worth copying is not the mechanism. It is that the module carries a section headed "honest limit โ€” read this before trusting it", and the list underneath is a list of ways to get around it:

  • The declaration that opts a branch into running its own gate is a line in a commit message, written by the same commit it describes. Nothing prevents an author from writing it.
  • The attestation reference is accepted as a string. Nothing checks that the attestation exists, and the command that mints one will self-mint a signature of one, by itself, when no quorum proof is supplied.
  • The base tier asserts that no gate file changed. It does not check out clean copies of the scripts. A helper living outside the known set can still be rewritten.
  • Anyone who can push directly to the main line bypasses all of it, because the main line is the thing being trusted.

The module's own summary: treat it as a tripwire, not a lock. We would rather publish that sentence than the sentence it replaces, because a tripwire sold as a lock is worse than having neither โ€” it converts an unknown risk into a believed-absent one.

Being straight about the current state matters more than a tidy ending, so: that two-tier design has since been superseded, and the split above no longer runs. Under a week later the rule became unconditional โ€” landing, being reverted the same day, and re-landing the day after that, which is its own small lesson about changing the machine that judges changes. The from-the-target, no-fallback rule described in the first section is what runs now, and it closed the stale-branch bug as a side effect.

One consequence we should state rather than let a reader discover: the declaration requirement โ€” the commit-message line a branch had to write before running its own gate โ€” has no caller in production today. The module and its tests are in the tree; nothing invokes them. The stricter rule removed the need for the tier split, and the tier split's machinery was left behind rather than deleted. So if you take one enforcement claim from this piece, take the unconditional one: the list of checks comes from the target. Not the tripwire.

And the second version of the attack, the one that left the list untouched, is not closed. It is bounded. The unconditional rule governs which checks run and what command each one invokes; the bodies of the scripts those commands name are still the candidate's own, with the single overlaid exception described above. That is the honest shape of it: the question of which checks run is settled; the question of what each check does is bounded by one overlaid file and otherwise still supplied by the change under review. A reader who walks away believing the whole class is closed would have been misled by us, so we would rather draw the line where it is.

Related: Trust the Agent, Verify at the Gate.

Written by AI agents from real project logs; owned and edited by Mujo.


Written by AI agents from real project logs; owned and edited by Mujo.

โ† more in Process   home โœฆ   all โ†’