Leave And Accrual
Published
A previous pass in this series established that a numeric field which loses its name does not become zero, it becomes incomparable, and that every threshold against it fails. This pass measured which way each threshold falls. The answer is not a property of the field or of how important it sounds: it is decided by whether the comparison asks whether there is enough, or whether there is too much.
The plan, and the two year-end options pointing opposite ways
The sample is an ordinary leave plan. Time accrues at roughly 0.0577 hours for every hour worked. Accrual stops at an annual cap of one hundred and sixty hours. At the year end, anything above a carryover limit of forty hours is forfeited. Leave may not be taken before it is earned, and new hires wait ninety days before booking any at all.
Two of those options sit on the same object and their library defaults point in opposite directions. With no annual cap the library never stops accruing. With no carryover limit the library forfeits everything at the year end. One default grows a liability quietly; the other empties every balance in the company on the first of January.
Unprotected, a one-hundred-and-fifty-hour balance accruing a quarter's worth of work clamps to the cap at exactly one hundred and sixty hours, and a one-hundred-and-ninety-hour balance rolls to forty with one hundred and fifty forfeited.
The loud half runs last, so the loud half wins
Renaming annualCapHours alone removes the ceiling. The balance that should clamp at one hundred and sixty accrues to 173.08 instead, and reports capped=false. Nothing is refused and nothing is logged; the balance is simply larger than the policy allows, and stays that way until somebody resigns and cashes it out.
Renaming carryoverLimitHours alone reverts it to zero, which means everything is forfeited. The year-end roll takes a one-hundred-and-sixty-hour balance to carried=0h forfeited=160h. Every employee in the company loses their entire leave balance overnight, which is discovered immediately and loudly.
Renaming both took the balance to carried=0h forfeited=190h -- the loud outcome, not the quiet one. This is the mirror of what the same shape produced in an earlier pass on a data-retention engine, where a surviving dry-run flag masked a destroyed purge scope and the combined arm read clean. The difference is only which option the library consults last. Here the carryover clamp runs after the cap, so it decides. Which half wins is a property of evaluation order, not of severity.
Two hundred hours against a balance of eight
The sharpest arm in the file renames balanceHours, a plain number on the employee's record. The refusal is written the ordinary way: refuse when the request is larger than the balance. With the field renamed that comparison becomes 200 > undefined, which is false, so the refusal never fires. A request for two hundred hours against a balance of eight was granted in full, and the record printed granted(balance-was=undefinedh left=NaNh).
The balance is not treated as zero. It is treated as a value nothing can be compared against, and the guard falls through. The employee is granted five weeks of leave they have not earned, the record of the grant is well formed, and the only oddity is a balance figure that reads undefined in a log nobody is watching.
The same arm also demonstrates a boundary effect. The identical overdraw presented on a record that arrived as JSON from the HR system of record was refused exactly as before, with insufficient-balance(asked 200h, has 8h). Keys inside stored JSON are text and arrive spelled as they were written; keys in an object the application builds in code are names, and names move. The exposure is the record you assemble in memory, not the row in your database.
Which direction a threshold fails, and why
This pass measured the same undefined value against two thresholds phrased in the two ordinary ways, and they fell in opposite directions. The leave check asks whether the request is too large: requested > balance. Against undefined that is false, the refusal is skipped, and the request is granted. It fails open.
A benefits check measured the same pass asks whether the hours are enough: it refuses when the weekly hours are not greater than or equal to a minimum. Against undefined the comparison is false, the negation is true, and the enrolment is refused. It fails closed.
So the direction is decided by the phrasing of the comparison, not by the field. A ceiling test -- is this too much? -- fails open, because the thing it was watching for cannot be established. A floor test -- is this enough? -- fails closed, because sufficiency cannot be established either, and unestablished sufficiency reads as insufficient. That is worth knowing before predicting how any threshold in your own code will behave, and it costs nothing to phrase a critical one as a floor.
The rest of the surface, in one list
Renaming allowNegativeBalance reverts it to the library default of true, and the same two-hundred-hour request is granted against the eight-hour balance -- this time honestly, with balance-was=8h left=-192h. Both the stored record and the in-code record are affected, because this is an option rather than a record field. Renaming waitingPeriodDays lets a new hire book leave from their first day.
Renaming hireDate is more interesting than it looks, because that field is read by the installed library rather than by the sample. The waiting period is measured against it, so the write moves and their read does not: the new hire inside the waiting period stops being refused for that reason.
Renaming accrualRatePerHour reverts it to zero, so nothing accrues at all. That is the loudest failure available here and the one most likely to be caught, which makes it the least dangerous item in the list.
What protection alone did, and what to do about it
Nothing. The file was protected on five profiles -- the ES5 default, the modern target, both emit-gate configurations and the string-encoding profile -- and produced byte-identical output on all five. Every balance, grant and refusal matched. Renaming identifiers and encoding strings does not disturb an accrual engine.
Every result above required member renaming with a pattern naming those members. Keep it anchored to names your own code owns on both sides, and remember that the records you hydrate in memory are the exposed surface here, not the rows in the database they came from.
The mitigation for the threshold results is one line and is worth having regardless of obfuscation, because a bad configuration file produces the same value: require the number to be finite before you compare it, and treat a non-finite balance as a refusal rather than as a comparison that quietly returns false.
Frequently asked questions
Does obfuscating an HR bundle change anyone's leave balance?
Not by itself. The sample was protected on five profiles including the ES5 default, the modern target and string encoding, and produced identical balances on all five. Every result in this article required member renaming, which is off unless you enable it and supply a pattern.
What happened when the leave balance field was renamed?
A request for two hundred hours against a balance of eight was granted in full. The check refuses when the request exceeds the balance, and a comparison against undefined is false, so the refusal never fired.
Does a renamed number become zero?
No, and that is the point. It becomes a value nothing can be compared against. Comparisons return false rather than throwing, so the guard is skipped rather than tripped, and the value still prints and still reaches logs and dashboards.
Why did one threshold fail open and another fail closed?
Because of how each was phrased. A ceiling test asking whether a request is too large fails open against an unreadable value. A floor test asking whether hours are sufficient fails closed, because sufficiency cannot be established and unestablished sufficiency reads as insufficient.
Was the record from the database affected too?
No. The same overdraw presented on a record parsed from stored JSON was refused exactly as before. Keys in stored JSON are text and arrive as written; keys in an object your code builds are names and move with the rename.
What did renaming the two year-end options do?
The cap alone let a balance accrue past its ceiling to 173.08 hours, quietly. The carryover limit alone forfeited every balance in full. Renaming both produced the forfeiting outcome, because the carryover clamp runs last and therefore decides.
What is the one-line mitigation?
Require the number to be finite before comparing it, and treat a non-finite value as a refusal. That is worth doing regardless of obfuscation, since a malformed configuration or a partial API response produces the same value.
Related reading