Threads · 17 / 18 · Sep 7, 2026 · Apache-2.0
Your deprecation window cannot be shorter than your oldest pending approval
Thirty days of notice, a changelog entry, a clean removal — and a live Approve button that can never work again, because the thing it would run was frozen against a contract you just deleted. Then somebody patches the error away, and it starts issuing four thousand dollars where forty were asked for. A proposal cannot outlive the contract it was frozen against — so either it expires first, or the
The setup, which is not unusual
An action that is not autonomous does not run. It freezes into a proposal — the arguments it was asked with, and the contract version those arguments were validated against — and waits for a person.
- Day 0: a credit of
4000cents is proposed againstv1. - Day 10:
v2ships; both versions live side by side.v1is deprecated with a 30-day window. - Day 41: the window elapsed, so
v1is removed. By its own rule, this is correct.
The owner was on holiday, then it needed a second signature, then it sat in an inbox.
The button is still there
expect(approvals.buttonIsShown(proposalId)).toBe(true);
expect(() => approvals.approve(proposalId)).toThrow(ContractGone);
The proposal cannot be approved and was never rejected. It is a decision your system asked a human for, and then made unanswerable — and nothing in the deprecation process could have noticed, because deprecation was counting days and the problem was counting proposals.
Forty-five days is not an unusual approval. It is a Tuesday in a company with more than one person in it.
The obvious patch is worse
Somebody sees ContractGone in the logs and makes it stop: fall back to the current version.
expect(approvals.approveFallingBackToLatest(proposalId)).toEqual({ cents: 400_000 });
No error. A green button, a satisfied approver, four thousand dollars where forty were asked for — because
v2 reads the same number as dollars.
Same field, same type, same range. The shape never changed, so nothing rejected it; only the meaning changed, and meaning is the one thing a validator cannot check. Which makes the loud, useless failure the lucky one.
The number was never a guess
expect(blockers(approvals, "billing.credit.issue", 1)).toEqual([proposalId]);
Not an estimate of how many clients are still out there — a list, of objects this system created, sitting in its own database, with their ages. The window was invented next to a fact.
So the removal asks:
expect(() => removeWhenUnreferenced(registry, approvals, …, day(41))).toThrow(StillInUse);
expect(approvals.approve(proposalId)).toEqual({ cents: 4000 }); // still answerable, still right
It refuses, and names what is holding the version. It does not resolve the proposals for you: auto-approving executes something nobody approved, auto-rejecting discards a decision somebody is entitled to make. Refusing is how the system asks for a person.
And the refusal has to be finite
A proposal that can sit pending forever blocks a version forever, and "remove it when nothing references it" quietly becomes "never remove it". So a proposal gets a maximum age, and the window is derived from it:
expect(windowDaysFor(30)).toBe(37); // max pending age + grace
approvals.expireOlderThan(day(41), 30);
expect(removeWhenUnreferenced(registry, approvals, …, day(41))).toBe(true);
Whatever is still pending when the window opens has expired before it closes. The two halves are one rule:
A proposal cannot outlive the contract it was frozen against — so either it expires first, or the contract waits.
An expired proposal is also a finished story with a name. Somebody can see it lapsed and ask again; a pending one against a deleted contract is a control that answers nothing.
Where this generalizes
Anything you freeze against a version and resolve later has this shape: queued jobs holding a payload schema, scheduled sends against a template, a checkout resuming after a price list was retired, a signature request against a document revision. The deprecation window is a promise to whoever still holds a reference, and your own pending work is a holder you can enumerate exactly — unlike the clients, whom you can only guess at.
The counter-intuitive part: the retirement calendar belongs to the queue, not to the release notes.
Where this comes from
niiko lets an owner set an action to propose, do not execute. Versions coexist for a declared window, and the window is bounded below by how long a proposal may stay pending — which is why the proposal has a maximum age at all.
Related: typed-refusals, for why needs approval is a state of the product rather than an error.
License
Apache-2.0 — see LICENSE. This is a demonstration, not a package. Copy what you need.
Built by Vorluno — a software studio from Panamá.
// next threadYour open-core boundary check is blind three ways