Ground Truth

Year-End Is a Rule, Not a Reset

Mostafa DhouibMostafa Dhouib·10 min read
The short answer

Carry-over, expiry and lapse at a period boundary are derivation rules applied at a date. Implemented as a destructive reset that overwrites the previous period's state, the previous year becomes unreconstructable and any retrospective correction is impossible. The reset version is always the one chosen first, because it is a smaller piece of code and the cost only appears months later when somebody asks a question the system can no longer answer.

Year-End Is a Rule, Not a Reset

The short answer. Carry-over, expiry and lapse at a period boundary are derivation rules evaluated at a date, not an operation that rewrites the previous period. Implemented as a reset, which is how it is nearly always implemented first, the job overwrites each person's allocation with a new figure and the previous year stops existing. From that moment nobody can reconstruct last year's balance, nobody can explain how the carried amount was computed, and a retrospective correction to the previous period is not merely hard but impossible, because the inputs the correction would operate on were overwritten. Expressed as rules over an unchanged record, the same boundary becomes a derivation that can be re-run.

Year-end comes up late in these projects. The main flows are built, the screens work, and somebody notices the plan has a line called year-end rollover with a day and a half against it.

A day and a half is roughly right for the reset. It is nowhere near right for the rule.

What the reset does

The reset is a batch job. It runs on a date, iterates over every person, computes the amount to carry forward, and writes a new allocation. Something close to this:

UPDATE balances
   SET allocated = LEAST(allocated - used, carry_cap) + next_year_entitlement,
       used      = 0,
       pending   = 0,
       period    = period + 1

It is short, it is easy to read, and it produces correct current balances on the first of January. Every acceptance test anybody writes for it passes.

What it also does is destroy its own inputs. The previous period's allocated and used figures are gone, replaced by a single number that is their arithmetic consequence. The carried amount is now indistinguishable from the new entitlement, because both were added into one column. And the rule that produced it, the cap, the eligibility condition, the treatment of people who joined mid-year, exists only in the code as it stood on the day the job ran.

The failure point
One update statement overwrites its own inputs
the previous period's allocated and used figures are replaced by a single number that is their arithmetic consequence, and the carried amount is added into the same column as the new entitlement
Every acceptance test anybody writes for it passes, because the current balances it produces are correct
How much of my balance is carried over has no answer, because the distinction was summed away and carried days often expire earlier than newly granted ones
The rule that produced the figure, the cap, the eligibility condition, the treatment of people who joined mid year, exists only in the code as it stood on the day the job ran
A week of leave recorded against the wrong person in November cannot be corrected, because last year's used figure no longer exists to be corrected and this year's allocation cannot be recomputed from inputs that are gone
The only available repair is to adjust this year's number by hand and write a note somewhere.
FigureThe reset is short, it reads well, and it produces correct balances on the first of January. What it also does is destroy the figures it computed from, which is why a question asked the following October is unanswerable rather than merely difficult.

Three questions become unanswerable at that point, and all three get asked.

How much of my balance is carried over? Relevant because carried days often expire earlier than newly granted ones. The system cannot tell, because the distinction was summed away.

What was my balance at the end of last year? Asked whenever a figure is disputed, and asked by auditors as a matter of routine. The system has the figure that resulted from the reset, not the figure that preceded it.

Can you correct last year? Somebody discovers that a week of leave in November was recorded against the wrong person. Correcting it changes last year's used figure, which changes the carried amount, which changes this year's allocation. Under the reset, last year's used figure no longer exists to be corrected, and this year's allocation cannot be recomputed because the computation's inputs are gone. The only available repair is to adjust this year's number by hand and write a note somewhere.

The boundary as a rule

The alternative does not run a job that changes the past. It adds entries that describe what the boundary did, and it leaves everything before the boundary untouched.

Concretely, at the close of a period the system posts entries rather than overwrites columns. An expiry entry per account for the portion of the previous period's remaining balance that lapses, negative, dated at the period end, with a type that says it was an expiry and a reference to the rule version that decided it. A carry-over entry, positive, dated at the start of the new period, for the amount that survives. A new allocation entry for the new period's entitlement, separate from the carried amount so the two remain distinguishable.

Nothing that was already recorded changes. The previous period's balance is still the sum of the previous period's entries, and it still computes to the same figure it always did. The new period's opening balance is the carry-over entry plus the allocation entry, and each of those carries the reason it exists.

Now re-ask the three questions. How much is carried over: read the carry-over entry. What was the balance at the end of last year: sum entries with an effective date on or before the period end, which is the same derivation used for every other date. Can you correct last year: yes, post the correction as a dated entry in the previous period, then re-derive the boundary.

That last point is the one worth dwelling on.

Retrospective change becomes re-derivation

Under the rule model, a correction to a closed period is not a special operation. It is an entry with an effective date inside that period and a recorded-at timestamp of today.

Posting it changes the previous period's closing balance. The carry-over and expiry entries that were derived from that closing balance are now wrong, and the system knows they are wrong because it knows what derived them. So the boundary is re-derived. The old carry-over and expiry entries are reversed, new ones are posted with today's recorded timestamp, and the current balance moves by the correct amount.

Everything in that sequence is visible. Somebody can see that the opening balance for this year changed in October, see the correction that caused it, see which rule version produced the new carried figure, and see what the opening balance was believed to be before the correction landed. That last fact is why the recorded-at timestamp has to exist separately from the effective date. Without it, re-derivation silently rewrites what every earlier report said, and the question in a dispute is usually not what the right number is but why the number changed.

The practical requirement this places on the implementation is that the boundary derivation has to be idempotent and re-runnable. Running it twice for the same period must not produce two carry-over entries. That means the entries it produces are keyed by account, period and type, with a uniqueness constraint behind the key, so a re-run replaces rather than accumulates. This is the same mechanism that makes any other money-adjacent job safe to retry, and it is cheap to put in at the start and awkward to retrofit.

Why the destructive version always wins first

It is worth being honest about why this keeps happening, because the pattern is too consistent to be carelessness.

It is the smaller piece of code. One update statement against several entry types, a rule version, a reversal path and an idempotency key. If the specification says reset annual leave on the first of January, the update statement satisfies it.

The cost is deferred past the point anyone is watching. The reset produces correct current balances. Acceptance passes. The project closes. The cost arrives in the following October, when somebody needs a retrospective correction and the system cannot provide one, by which time the team that built it has moved on and the failure reads as a missing feature rather than as a design decision.

The word in the requirement is "reset". Business stakeholders describe the boundary the way they experience it. Balances reset at year-end. Taking that literally as an instruction to overwrite state is a reasonable reading of the sentence and the wrong reading of the requirement.

Nobody asks the question that exposes it. The question is: after this job runs, what was the balance the day before? It takes ten seconds to ask and it settles the design. It is almost never asked, because year-end is scheduled as a batch job rather than discussed as part of the data model.

That last one is the cheap intervention. If you take one thing from this, make it a habit of asking that question whenever a scheduled job appears in a plan with the word reset, roll, close or archive in its name.

The gate
After this job has run, what was the balance the day before?
The system can still derive it
the boundary posted dated entries for expiry, carry over and the new allocation, and changed nothing that was already recorded, so the previous period still sums to the figure it always did
The system cannot say
the job is destructive, and the boundary needs modelling as a derivation before it runs for the first time rather than after somebody asks
A day and a half is roughly right for the reset. It is nowhere near right for the rule.
FigureOne question settles this design, and it takes ten seconds to ask. It is almost never asked, because year end is scheduled as a batch job rather than discussed as part of the data model. Ask it of any job in a plan whose name contains reset, roll, close or archive.

The honest limit

Rules over a preserved record are not free. The derivation at a boundary is more code than an update, the re-derivation path needs tests that most teams would not otherwise write, and the entry types multiply in ways that make the schema harder to explain to somebody new. On a system where nobody will ever need to reconstruct a prior period, that is cost without return.

There is also a genuine tension with data retention. An entry ledger that preserves every period forever conflicts with obligations to delete personal data after a defined time, and leave records are personal data. The resolution is usually to preserve full detail for the retention window and keep aggregate period-close figures beyond it, which means the far past becomes reconstructable only to the granularity of the aggregate. That is a defensible compromise and it does weaken the claim. Reconstructable at any date really means reconstructable within the retention window.

And rule versioning, which this article leans on, is the part I see skipped most often. If the carry-over rule changes and old boundaries get re-derived under the new rule, historical figures change without anybody intending it. Storing the rule version with the derived entry is what prevents that, and it only works if the old rule's code is still present and still executable. Keeping dead rule implementations alive for years is unglamorous and it is the actual cost of being able to explain a past number.

FAQ

What is wrong with a year-end reset job? It overwrites its own inputs. The previous period's allocation and usage are replaced by a single figure that is their consequence, the carried amount is summed into the new entitlement and becomes indistinguishable from it, and the rule that produced it survives only as the code that ran on the day. After that, last year's balance cannot be reconstructed and a correction to last year cannot be applied at all.

How do you express a period boundary as a rule instead? Post entries rather than overwriting columns. An expiry entry for the lapsing portion, dated at the period end. A carry-over entry for the surviving amount, dated at the start of the new period. A separate allocation entry for the new entitlement, so carried and granted amounts stay distinguishable. Nothing already recorded changes, so the previous period still derives to the same figure it always did.

How does a retrospective correction work under that model? The correction is an entry with an effective date inside the closed period and a recorded timestamp of today. It changes that period's closing balance, which invalidates the carry-over and expiry entries derived from it, so the boundary derivation is re-run. The old derived entries are reversed and replaced, and the chain from the correction to the change in the current balance stays visible.

Why does the boundary job need to be idempotent? Because it will be re-run, both for retrospective corrections and because scheduled jobs get retried. If it is not idempotent, a second run posts a second carry-over entry and doubles the opening balance. Keying the derived entries by account, period and entry type with a uniqueness constraint makes a re-run replace rather than accumulate, and that is far cheaper to build in at the start than to retrofit.

Why is the destructive version chosen so consistently? Because it is one update statement against a derivation with reversal and versioning, because it produces correct current balances so acceptance passes, and because the requirement itself usually says the word reset. The cost appears months later as an apparently missing feature rather than as a design decision, by which point it reads as a new request instead of a consequence.

What single question exposes the problem during planning? After this job has run, what was the balance the day before? If the answer is that the system cannot say, the job is destructive and the period boundary needs to be modelled as a derivation. The question takes ten seconds and it is worth asking about any scheduled job whose name contains reset, roll, close or archive.

Carrying a program like this one?

Tell us the system, the stakes, and the date that matters. You get a straight technical reply from the person who would lead the work, within 24 hours.

Bring us the program