WizusLabs Engineering · Craft

A running gate is not a lock

A verification pass was running, so four ready and independent lanes of work sat still. Nothing crashed, nobody argued, and every status report that turn read healthy — which is the only reason it lasted as long as it did.

By WizusLabs Engineering · 2026-08-19 · ~11 min read

Nine lanes of work were in flight in one of our game repositories. Then a whole-tree verification pass started, the count fell to one, and it stayed at one for several turns while every report said things were fine. The justification on the record was a single sentence: the gate holds a byte lease on all 1,429 files. It reads like a resource claim, and it is a category error — a verification pass reads the tree, it does not own it. The interesting part is not that the sentence was wrong. It is that we had already built a check for exactly this decision, the check fired correctly, and the sentence satisfied it. A gate a plausible sentence can satisfy is not a gate.

What a test run actually holds

Start with the resources, because the whole argument is a list of two things and one absence. A whole-tree verification pass holds a build cache — a directory of compiled intermediates that a second concurrent run would contend on, which is why you genuinely cannot start one. It holds the git index at the moment it commits, which is a shared mutable surface and the reason we commit by explicit pathspec rather than sweeping whatever a peer has staged. What it does not hold is the source tree. Its job is to read those bytes and report what they did. Reading is not owning.

The instrument that made this vivid for us is a whole-tree same-bytes baseline: hash every file before the run, hash them again after, so “the bytes we tested” and “the bytes we committed” are one claim rather than two hopes. It is easy to read that as requiring an idle tree. It does not. If a peer edits a file the run never touched, the baseline surfaces one attributable failed line naming that exact path, and every file under verification is still proven unchanged. The failed line is not damage. It is the instrument doing its job, out loud. A failed line you can attribute is a stronger artifact than an idle tree, because an idle tree cannot tell you whether the instrument was even alive.

Which reframes the timing question. A red gate invalidates the publish. It does not invalidate a lane that is halfway through editing a file, and lanes that land after it simply join the next gate. “Waiting for the gate” is a true description of a push and a false description of a decision, and we had quietly promoted it from one to the other. The gate runs in its own worktree with its own cache; a lane editing source in a different directory is not in its way and never was.

What an in-flight verification gate holds, and what it does not An in-flight whole-tree verification gate is shown holding three resources to different degrees. It holds the build cache, so starting a second test run is blocked. It holds the git index at the moment it commits, so a publish waits behind it. It does not hold the source tree, so editing a source file and running a static analysis pass are both ready to dispatch immediately. The conclusion is that a gate blocks the push, never the dispatch. The in-flight gate whole-tree verification reads every file · owns two Build cache HELD one run per cache Git index HELD AT COMMIT shared, mutable Source tree NOT HELD read, not owned second test run genuinely blocked a named resource the publish waits for the result this is the real wait edit · analyze READY — dispatch now no shared resource a gate blocks the push, never the dispatch
The same in-flight run, resource by resource. Two of the three columns are real constraints and the third is the one we treated as one — which is how a witness got promoted to a scheduler.

The locks that are real

This is not a “parallelize harder” post, and it would be a worse one if it were. Some constraints are genuine, and naming them exhaustively is what makes the rule usable rather than a slogan. There are five, and a justification that cites anything outside this list is invalid. File collision — two lanes’ write scopes intersect; name the exact path, because a vague “they might touch the same area” is not this. A shared mutable resource — the git index, a build cache, a device or simulator lease, a machine-wide build slot; name which one. Destructive mutation of a file a peer reads — temporarily neutralising a function to prove a test can fail, running a repo-wide formatter, applying a migration; name the operation. A true data dependency — lane B’s input is lane A’s output; name the artifact. And a contract bar — a lower-priority item held while a higher priority is open; name the priority.

Two of those we have written about at length, because both cost us real work before they were written down. The file-collision case is the one where two agents editing one file produce no merge conflict and no error — just a coin flip about whose edit survives. The shared-resource case has a limit we set deliberately and keep at one build at a time, machine-wide, because Xcode and Gradle and simulators do not behave like text searches and three simultaneous builds produce failures that read like project defects. That limit is real, it is measured, and we do not apologise for it. The point of an exhaustive list is precisely that it protects the entries on it.

Everything difficult about this reduces to one distinction, and it is the whole practical payload of the piece. A source edit and a static analysis pass are compatible with an in-flight test gate. A second test run is not. Same repository, same minute, same dirty tree — different resources. Once you are asking “which resource?” instead of “is something running?”, the answer takes about four seconds and stops being a matter of temperament.

The cost is real; the cost is not this

The honest complication is that contention is not free. On the repository in question, the same whole-tree verification pass took 677 seconds on a quiet host and 1,322 seconds under load — almost exactly double. Earlier serial gates on that repo have run anywhere from 17 to 82 minutes, with every code lane parked behind them. Those are good reasons to cap how many lanes run at once, and to say what the cap is and how you measured it. They are not reasons to run one. A ceiling is a number with a measurement attached; serialisation is a number with a mood attached, and the two get conflated constantly because both come out as “fewer lanes.”

Five sentences that read like blockers

Here are the justifications we have measured as invalid, each of which reads perfectly reasonable in a status report. “A verification gate is in flight” — true, and a blocker for the build cache alone, never for the source tree. “The tree is dirty” — a description of a tree with work in it, which is the normal state of a working repository. “Sequential is simpler” and its cousin “sequential is safer here” — preferences wearing the grammar of a constraint. “Everything I have identified is already owned” — not the same statement as “no work is available”, and in our case a signal to go and re-derive candidates from the carry-forwards buried in finished handoffs, every one of which turned out to hold dispatchable work. And the self-defeating one: “I would have to attribute the results afterwards.” Attribution is the purpose of the instrument, not a tax on using it.

What those five share is more useful than any of them individually. Every one names a condition — something that is true of the world right now — where the question asked for a resource. That substitution is invisible in prose because English is happy to let a condition sit in the slot where a cause belongs. It is not invisible in a table, which is the whole reason we stopped writing the paragraph.

Publish the enumeration, not the conclusion

The forcing function is the part a reader can adopt tomorrow, and it is deliberately mechanical. Before serialising anything, do not write a justification. Enumerate every open item and label each one with exactly one named blocker from the list of five, or with READY. Anything you cannot pin to a named blocker is READY and goes out this wave. Then publish the enumeration itself — the table, not the summary of the table.

The reason this works is not discipline; it is falsifiability. A reader can check whether two paths actually intersect, look up whether a device lease is held, ask who owns the artifact a data dependency claims to need. Every cell invites a contradiction. A prose justification invites nothing, which is exactly why ours survived several turns of review by competent readers who had no purchase on it. The diagnostic tell we now use on our own reports is embarrassingly simple: a wave report whose lane count is one and whose justification is a sentence rather than a table.

One adjacent habit came out of the same review, because a count can also drift downward without any decision being taken. Lanes die for reasons that have nothing to do with the work — an intermittent upstream error will kill a child mid-task all day. Each death arrives as a small piece of bookkeeping, and absorbing it quietly is how nine becomes one without anyone ever choosing that. So the count is monitored continuously rather than at wave boundaries, and a lane that finishes or dies is treated as capacity that just came free, not as a decrement to record.

The rule was four hours old, and its author broke it

Now the part that changed how we write rules at all. The principle that would have caught this — a byte lease is an attribution instrument, not a mutex — was not missing. It was written four hours earlier, by the same orchestrator that then violated it. Not inherited from a predecessor, not buried in a five-year-old wiki. Four hours, same author, same session.

That fact rules out most of the comfortable diagnoses. It is not ignorance and it is not carelessness, and treating it as either would have produced another sternly-worded rule that also failed. The actual cause is placement. That principle lived in a reference catalog whose mandatory load condition is “before dispatching work.” The harmful decision was made while declining to dispatch. The trigger never fired, because the trigger was attached to the good behaviour. A rule that only loads when you are already doing the right thing cannot stop you doing the wrong one.

Generalised: put the rule at the decision point, not the action point. And the corollary is the one we now use as a heuristic in its own right — when a rule is violated by its own author shortly after being written, suspect placement before discipline. It sits next to two failures we have already documented and keeps the same company. We once promoted a rule to a single canonical home and never retired the original, so the next two fixes patched the copy nobody read. And we once shipped a guard nobody had seen fail, which passed green for two days beside a file it could not in principle see. This incident is the third shape of the same family: a check that fires on time, in the right place, and accepts a wrong answer. All three report success. Only one of them was ever working.

What we changed, and what we did not

We did not add a rule. There was already a rule, and there was already a gate demanding a justification whenever ready lanes outnumbered active ones — and the gate fired exactly when it was supposed to. What we changed is narrower and duller: we constrained what a justification is allowed to say — five named blockers, nothing else admitted — and we moved that constraint to the moment the decision to serialise is taken, rather than the moment work is dispatched. A gate that accepts free-form prose is collecting paperwork. A gate with a closed vocabulary is asking a question that can be answered wrongly, and therefore checked.

No triumphant ending is available here, and pretending otherwise would undercut the point. This same drift has now been corrected four times on one axis, the most recent on 2026-08-19, which is the day this post went out. We do not know that the vocabulary constraint holds; we know that the previous three remedies did not, and that all three were rules rather than changes to where a decision gets made. More of these small operational disciplines, and the ways they keep failing in new places, are on the WizusLabs Engineering blog. The one line worth carrying out of it: a running gate is a witness. It reports on the bytes. It does not get to decide who is allowed to type.

Notes

This is a first-hand account of our own orchestration run, read out of our own dispatch-heuristics catalog, our own workspace instruction file, and the run’s own records on 2026-08-19. Every number in it was read rather than recalled: the lane count falling from nine to one, the four ready lanes that were frozen, the 1,429 files named in the false justification — quoted as the content of that claim, not as an independent measurement of the repository — the 677-second and 1,322-second wall-clock figures for the same verification pass, the 17-to-82-minute range for earlier serial gates on that repository, the four hours between a rule being written and being broken by its author, and the four corrections of this same drift. There are no throughput, velocity, or productivity figures anywhere in this post, because we have none we would stand behind: we do not measure lanes-per-hour or work-completed-per-wave, and manufacturing a “3× faster” number to make the argument land would be the exact failure the piece is about. Three scope limits worth stating plainly. The wall-clock figures come from one host, one repository, under contention we did not control — they are evidence about a decision, not a benchmark. The blocker taxonomy is exhaustive for our toolchain; a different stack will have shared resources ours does not, and the honest use of the list is to re-derive it against your own tools rather than to inherit ours. And the incident is a single run: it is the reason we changed the practice, not proof that the change works.

Keep reading: all posts on the WizusLabs Engineering blog.

← Back to the Blog