Skip to content

Blog

Three explanations, and the one we never tested

Leif ·

This site runs spec-sync on itself. A change here is a directory of records living beside the code: the approved scope, the verification evidence, the review, all committed. The code ships when the pull request merges. The records close separately, when the change is finalized, which is also the step that archives it.

Those two things coming apart is the whole story. A change can be live in production while its records still say verifying.

Sixteen changes are archived. Nothing has archived since the fifteenth. Twenty three are open right now: eleven accepted, seven verifying, four implementing, one draft. Four changes shipped to production in a single day last week and not one of them closed.

We had a good explanation for that. It was wrong. So was the third one. The second is still standing, because nobody has run the command that would settle it.

The three explanations

Each concerns a delivery input: a file a change claims as something it delivers. When two changes claim the same spec file, they are coupled, and the coupling is what stalls.

Finalize cascades forward. Archiving one change invalidates its siblings that claim the same input, so the chain stalls after the first.

Finalizing the successors frees the predecessor. Older accepted changes are waiting on newer ones that never finished. Push the newer ones through and the older ones drain.

The remedy is rebase-merge. Recorded evidence names a commit hash and gets checked for ancestry, so squashing destroys it. Rebase instead.

Every one of those fits the evidence. That is exactly what made them worth believing, and exactly what made them useless.

The first died to one command

specsync change archive, on an accepted change picked because four later commits had touched the spec it delivered. The criterion was agreed before running it: if the first archive succeeds and the second fails, the cascade is real, and if the first fails immediately, it never was.

It failed immediately. One command, available all along, run a day late.

The third killed itself

Pull request #247 on this repo carries a section headed "Merge this with rebase, not squash," explaining that squashing would rewrite the evidence hashes. I wrote it. The repository permits only squash merges, so #247 was squashed, and its evidence commits stopped being ancestors of main the moment it landed.

Eleven minutes later, an issue was filed against spec-sync prescribing the same remedy.

The instruction and its own refutation, in the same pull request, eleven minutes apart. The remedy does not exist on a squash-only repository, which is GitHub's default posture, and gh pr merge --rebase does not say so. It squashes and reports success.

The second is still untested

This is the one worth sitting with.

specsync change ship on the newest change was supposed to reach the question. It never got there. It advanced the record from verifying to accepted, wrote its finalization file, and then refused at a preflight: archiving this change would invalidate an earlier one claiming the same spec.

So the route to the test died, not the explanation. And the refusal left that change somewhere worse than where it started. check refuses to run on an accepted change. archive refuses because of the predecessor. The status output still prints check --commit as the next step, a verb its own state machine rejects.

One change with no forward verb at all, and twelve more coupled to it through a single shared spec. The command that would settle the second explanation has still not been run.

What it actually is

Merging before finalize leaves a change sitting in verifying. It never becomes the accepted successor that earlier changes are waiting on, so every earlier change claiming that spec is blocked from archiving.

Not forward into siblings. Backward into predecessors. Shared delivery input is the coupling in both stories, which is why the wrong one fit for so long.

What came out of it

Six issues against spec-sync. The strict-mode blocker with no workaround, already fixed and shipped. The evidence-to-hash binding no squash-only repository can satisfy. The archive refusal that binds in both directions and tells you about neither. The unreachable next step, pinned to two lines where one predicate admits a state the other rejects.

And the runtime warning that priced merging early at one orphaned record when it actually blocks an unbounded set of predecessors. The tool warned us, and the warning was wrong about its own cost. That wording was fixed today.

The one thing that worked as designed: the only door out of the wedged state is reopen, which takes a named actor and a written reason and puts both on the record. An agent should not walk through that alone, and this one did not.

The first explanation cost one command to kill, and we ran it a day late. The third killed itself the moment we acted on it. The second is still alive, and the command that settles it is still unrun.

← All posts