v1.14.5 ·
The number that was always missing by seven minutes
The problem
Each of these release posts carries a small card at the top with two numbers: what the work cost, and how long it took from the moment the work was created to the moment it was finished. For twelve of the first twenty-six posts, the second number is missing. Not zero — missing, printed as "not recorded".
The reason is an ordering problem, and it is the kind that hides because both orderings look correct.
The post is generated during the release itself, in the narrow window after the version tag exists and before anything is pushed. At that instant it reads the project's own metrics once, writes them into the post, and never looks again. Reading once is deliberate: a post should say what a piece of work cost when it shipped, not drift upward every time somebody later fixes a bug in it.
The consequence nobody spelled out is that whatever is unknown at that instant is unknown for good. And the elapsed time of a piece of work is unknowable until the work is marked finished, because there is nothing to subtract from. So the question of whether a post can state its own duration comes down entirely to whether the work was closed a few minutes before the release ran or a few minutes after.
Both orders are defensible, which is why this went unnoticed for twenty-six releases. Closing the work first means the record of the release describes a plan. Closing it afterwards means the record can describe what actually happened — the version that really appeared, the checks that really passed — which is plainly the more honest document, and it is the order a careful person naturally chooses. It also silently discards the duration.
The evidence is unusually clean. Line up, for each release, the moment the work was closed against the moment the post read the metrics: the sign of the gap predicts the outcome every single time. One release closed its work eighty-seven seconds before generation and recorded a duration of just over twenty-one hours. Another closed six minutes before and recorded six and a half. Two closed thirteen and seven minutes after, and recorded nothing at all. There is no third pattern.
What makes it worth fixing rather than documenting is that the loss is permanent and invisible. Nothing fails. No warning appears. Regenerating the post later does not repair it — the generator deliberately carries the old numbers forward rather than refreshing them, so a second attempt reproduces the gap faithfully. The only moment at which anything can be done about it is before the tag is created, and at that moment nobody is thinking about it.
How it could be solved
Three ways out, and the choice between them turned on which one would still be working in a year.
The first was to remember. Write down the correct order — close the work, then tag — and rely on whoever runs the next release to follow it. This project has a considerable amount of evidence about how that goes. A note in a release record naming an exact fix was rediscovered from scratch three days later and re-done as new work. A note in the post manifest saying "add an entry once the work has a record" was never re-read, and two releases shipped with no post at all while every check stayed green. The lesson is already written down twice: an instruction recorded as prose is not a mechanism, and the fact that it is correct makes no difference to whether it runs.
The second was to make the number recoverable after the fact, so the order stops mattering. This is what happens with the cost figure: the release reconciles the accounting immediately before generating the post, and anything that had not been tallied yet gets tallied then. It works for cost because the underlying usage is already sitting on disk waiting to be added up. It cannot work for duration, because before the work is closed there is genuinely no end time — the value does not exist to be recovered. Regenerating later would not help either: the generator carries old numbers forward rather than re-reading them, on purpose, so that a post keeps saying what it said the day it was published.
That leaves the third: refuse. Check, before anything is touched, that every piece of work this release claims to describe has actually been closed, and stop the release if not. This is the option that survives being forgotten, because forgetting is the failure it detects.
Two details of the refusal took more thought than the refusal itself.
The check tests for the exact number the post will print, rather than for the condition that produces it. "Is this work finished" and "does this work have a recorded duration" are, today, two ways of asking the same question — they come from the same piece of code. Asking the first would have been the obvious choice and would have read more naturally. Asking the second means the check cannot quietly stop agreeing with the thing it protects, which is the only way this class of check dies. It also turned out to be the shorter of the two to write, so there was nothing to trade away.
And it has no way to be switched off. The temptation is real — someone will eventually want to ship with a piece of work still open, and a bypass flag makes that a five-second decision. But this project has watched exactly that play out: one gate with no override forced five badly-written criteria to be fixed properly, while a gate with an override was waved through on every single phase of the same release by reflex. So there is no flag. The two escape routes that already exist are the honest ones: say the release does not cover that piece of work, or say it publishes no post — and in both cases write down why, because the format demands it.
The failure message ended up mattering more than expected, because the remedy is counter-intuitive. Being told "this is not finished" invites you to go and finish it, which here means publishing — the very thing the check has just prevented. So the message says the non-obvious thing outright: close the work from the plan, tag, and then append what actually happened to the record afterwards. The published post reads the record as it stood at tag time regardless, so nothing about it gets worse.
How AIDLC solves it
scripts/check-release-readiness.mjs (new)
checkReadiness(version, manifest, metrics) — pure, so the decision table is
testable against states this repo is not in. Given the manifest entry for
v<version> and one aidlc metrics --json payload, it returns
{ ok, skipped, blockers }.
It blocks when any instance named in the entry's instances has no
lead_time_seconds, distinguishing three causes in the message: the terminal
phase is still open, the instance is complete but its lead time is underivable, or
metrics does not report it at all.
It skips — ok: true with a stated reason — when there is nothing to protect: no
manifest entry for the tag, publish !== true, kind !== 'generated', or no
instances named. Entries under omits are ignored, because only instances feeds
buildMetricsSnapshot.
The runnable half reads the manifest, shells out to the workspace CLI build
when one is present and only falls back to a globally installed aidlc, and on
failure prints each blocker plus the three-step remedy.
scripts/release.sh
One pre-flight step, placed immediately after the deferred-blog check and before the version bump — so it runs while there is still nothing to unwind:
echo "→ Checking release readiness (lead time is recordable)..."
node scripts/check-release-readiness.mjs "$NEW_VERSION"