Skip to content

v1.12.0 ·

The fix that deleted its own alarm

The problem

Two days before this release, v1.10.8 fixed cost capture going dark. Hook commands had been embedding absolute paths to the node binary and the CLI, frozen at install time, so any change of username or machine or node version broke them silently. The fix replaced those paths with a small generated script that resolves both at run time and writes to a log when it cannot. It worked, and it was the right fix.

Two days later capture was dead again, for forty-one hours, and the command whose entire job is to report whether capture is armed said installed the whole time.

The script was not on disk. It is generated per install and deliberately not tracked, because it embeds resolution hints specific to one machine — committing it would make one developer's answer everybody's. But the settings file that arms the hooks is tracked. So the two halves of one mechanism have different lifetimes: the instruction to run the script survives a clone, and the script does not. Every fresh checkout starts armed against a file the repository cannot supply, and says nothing.

The sharper half is why nothing caught it. The earlier fix had also deleted the check that asked "does the thing this hook points at still exist?" The reasoning was written down and sounded right: the hook no longer names a path, so there is no path left to go stale. The premise was true and the conclusion did not follow. The hook's path could not go stale; the file it resolved to could be absent. Nothing replaced the deleted check, and the retrospective recorded the failure class as retired rather than merely detected. It returned the next day through the gap the retirement left.

There was a second failure underneath, and it had been quietly true for a week. The command that reconciles historical usage recovered nothing at all — forty-two of forty-two sessions skipped, zero attributed. Its join was on session id. When the hook cannot run, the session-start half never records the real platform id, so a claim falls back to a synthetic one. The identifier on the claim and the identifier on the usage record are then two different names for the same work, by construction, and no equality join between them can ever match.

How it could be solved

For the blindness, three options.

Track the generated script. Simplest, and wrong for the reason it was untracked to begin with: it carries machine-specific paths, so committing it hands one machine's answer to everyone and reintroduces the failure v1.10.8 had just removed.

Go back to absolute paths in the hook command. This undoes v1.10.8 wholesale to fix a smaller problem than the one v1.10.8 fixed.

Keep the script and check that it is there. Cheap and obviously correct once stated. The only real question is who does the asking, because a hook that cannot start cannot report that it did not start — so the check has to live outside it.

For the attribution, the useful observation was that the identifiers disagree but the clocks do not. A claim knows when it was held. A usage record knows the window it covers. Those two windows line up exactly, and they were sitting on disk the whole time. The join that would work had always been available and was never used, because the obvious key was the one that looked like an identity and was not.

That is also where the care was needed. Attributing on time invites two ways to be confidently wrong: crediting work to the wrong instance when two were claimed at once, and sweeping in usage from an entirely different project that happens to share the hour.

How AIDLC solves it

The status command now distinguishes a third state. Where it reported installed or not installed, it reports armed-but-not-working, naming what is wrong — missing, not a regular file, not executable — and the command that repairs it. Three readers were saying the reassuring thing and now say the accurate one, including the report note that used to advise running the doctor while no migration could fix this, and pointed at the status command for confirmation, which said installed.

A doctor migration regenerates the script. It is safe-tier and applies without asking, because the file is generated, holds nothing anyone authored, and rewriting it destroys nothing. Report-only was tried for this exact failure class once already, in v1.10.8's predecessor: the detection was correct, the message printed on every update, and the data was lost anyway, because the last link in the chain was a person reading a line. A repair that needs to be noticed is a repair that does not happen.

Reconciliation grew two passes. One walks the local usage records directly instead of trying to locate one from a session id, which is what makes usage from a session that never claimed reachable at all. The other moves already-captured records out of the unattributed ledger into the instance whose claim window covers them, deciding on time rather than identity. On this repository that recovered 811 records and captured about 4,400 that had never been recorded, taking instances that reported no cost data from ten down to three.

Three traps turned up while building it, and each one now has a test.

An unattributed record's session id may be a provenance stamp rather than an identity. When the older pass cannot decide between two claimants it writes the record under the first one's id, for provenance. Reading that back as identity finds exactly one matching claim window, looks unambiguous, and silently attributes contested money. It restated one instance eightfold before two existing acceptance tests caught it, which is the strongest argument for those tests that this project has produced.

A duplicate guard keyed on the field that disagrees is not a guard. The first version checked session id plus coverage, which does nothing when the ids being compared are the two names this whole defect is about. It looked right, passed the entire suite, and produced several thousand phantom records. The evidence was not in the code; it was in a diff of the dollar totals before and after.

Before attributing by time, ask what else shares the clock. The transcript directory holds every project a machine has ever worked on — twelve of them here, including unrelated repositories. A time join across all of it would have charged this project for other work. Scoping now goes through each record's own declaration of where it ran. A total that is too low is visibly incomplete; a total that is too high is indistinguishable from the truth.

One thing is deliberately not fixed. Attribution repairs itself for a single session per checkout, because the session-start half writes the identity again. Two concurrent sessions in one checkout still fall back to synthetic ids, and 1,077 records remain unattributed for exactly that reason, with 2,969 more because no instance was claimed when the work happened. Both are now reported out loud instead of rendering as zero. Declining to guess is a feature; declining to say so was the bug.

This post is hand-written because there is no lifecycle instance behind it. The work was done straight through, deliberately, at the maintainer's request to stop circling the problem — which means there is no ideation, no requirements and no design record to generate a post from.

That is worth saying plainly rather than hiding, because it is the same shape as the defect. A release whose entire purpose is to make the project's own measurement honest is itself attributed to no instance, and contributes to the 2,969 records above. The lifecycle records what goes through it. This did not.