Created
July 8, 2026 23:08
-
-
Save prfraser/dd35e3cfe7aab3328c2daf0e388dad54 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Award template rule identity: why we keep chasing the same bug | |
| **Status:** discussion draft — not committed policy. | |
| **Audience:** engineering + compliance. Assumes familiarity with managed award templates; no code-reading required. | |
| ## TL;DR | |
| Four recent incidents (five PRs, plus two closed attempts) are the same underlying defect appearing in different places: **generated award rules carry their meaning in their names, and consumers select rules from name-keyed groups using incidental orderings.** Every time a template update adds, renames, or reorders rules, some downstream consumer's assumption about names breaks — and the failures are pay-affecting (a $0-costed shift, a missing earnings rate, a blocked pay run, a junior underpaid on higher duties). We have fixed each incident tactically. This document lays out the pattern, what is still exposed, four structural directions, and one genuinely open question about the fix currently in flight (#55454). | |
| ## Terminology (used consistently below) | |
| - **Template** — a YAML fixture (`lib/award_templates/*.yml`) that generates concrete `Award`/`Allowance` rows for each organisation (via its **ATO**, `AwardTemplateOrganisation`). | |
| - **Code name** — the numeric prefix of a rule's name. It encodes *type* and *rate period*: `09.x` = base rate (dollar `hourly_rate`), `03.x` = penalty (percentage `multiplier`), `09.2526xx.x` = a 2025/26-period base rate. | |
| - **Export name** — the string that maps a rule to an external payroll concept (earnings rate, STP category, customer paycode). Customer **paycode mappings** are keyed on generated export names. | |
| - **Higher duties (HD)** — paying an employee at a higher level's rate, either **direct** (shift in an HD team) or via **overflow** (a long-enough HD shift lifts the whole day). HD rules are generated per level jump; some carry a **baked multiplier** (a stored cross-level ratio) rather than a dollar rate. | |
| - **Age bands** — per-age base rates (U17, 17, 18, 19, 20+). **`- Adult` rates** deliberately have *no* bands: juniors are paid the adult rate (e.g. for serving alcohol). | |
| - **Representative selection** — picking one rule from a group to act for the group. The recurring bug site. | |
| ## The incidents | |
| | Incident | PRs | State | What broke | | |
| |---|---|---|---| | |
| | ENG-4635 — HD overflow, `- Adult` rates ($0 shifts) | [#55454](https://github.com/TandaHQ/payaus/pull/55454) | Open, QA-approved | Overflow fallback picked a penalty rule (no dollar rate) because `03.x` sorts before `09.x`; `- Adult` HD rules also filed under wrong code names (no age bands to match, wrong rate period) | | |
| | ENG-4359 — Health "Base Hourly" earnings rate not created | [#53811](https://github.com/TandaHQ/payaus/pull/53811) (closed), [#54714](https://github.com/TandaHQ/payaus/pull/54714) (merged) | Fixed | Exporter groups rules by export name and samples `fetch(0)`; after [#49702](https://github.com/TandaHQ/payaus/pull/49702) added per-classification `09.x` rules, `"09"` sorted before `"1"` and the wrong *kind* of rule was sampled, then skipped | | |
| | COMP-1437 / ENG-4625 — RIA allowances blocked pay runs | [#55376](https://github.com/TandaHQ/payaus/pull/55376) (merged) | Fixed | Allowance export names didn't exactly match the required STP category strings; fixed by renaming via template migration | | |
| | ENG-4326 — juniors underpaid on HD (cents, amplified by penalties) | [#53471](https://github.com/TandaHQ/payaus/pull/53471) (closed), [#53561](https://github.com/TandaHQ/payaus/pull/53561) (closed) | **Unfixed** | HD baked multipliers are calibrated from adult rates (`max_by(:min_age)` always selects the adult band) and then applied to a junior's own base rate | | |
| Contributing template changes (correct in themselves): [#48642](https://github.com/TandaHQ/payaus/pull/48642) added level-specific HD penalty variants (RIA/FFIA), [#49702](https://github.com/TandaHQ/payaus/pull/49702) added per-classification health base rates. Each inserted new rows into an existing name-keyed namespace and broke a consumer that assumed the namespace was homogeneous. | |
| ## The common pattern | |
| **1. Names are the API.** Type lives in the code-name prefix, rate period in the code-name infix, level relationships in name suffixes (`(Higher Duties Level 2 -> Level 4 - Adult)`), and external payroll identity in export names — which even embed multiplier *values* (`"Fast Food - HD x2.44915 Ord x2.449"`). #53561 found that correcting a multiplier changes the generated export name and silently orphans the customer's paycode mapping: fixing a number breaks a join, because the number lives inside the name. Its workaround re-keyed mappings by rule name because, quoting the PR, *"the rule's full name is the only stable per-rule identity."* A stable ID (`templated_rule_id`) exists in the YAML but is not the join key anywhere that matters. | |
| **2. Representative selection by incidental ordering.** Each incident selects one rule from a group using an ordering that accidentally encodes the answer to a semantic question ("which *kind* of rule?", "*whose* rate?"): | |
| - ENG-4635: name sort (`by_probation_priority` tiebreaks on name) → penalty beat base rate → $0. | |
| - ENG-4359: string sort of code prefixes → hourly-rate rule beat multiplier rule → export skipped. | |
| - ENG-4326: `max_by(:min_age)` → adult band always wins → junior ratio wrong. | |
| **3. Template updates are the trigger.** The selections were "correct" until routine template work (new variants, renames, reorders, annual rate-period rollovers) changed group membership or ordering. This is why the bugs feel like whack-a-mole: the defect is in the consumers' assumptions, but it only *manifests* when Compliance ships a template change — weeks later, in a different subsystem. | |
| **4. The fixes so far disagree with each other.** #55454 fixed selection by *type* (`Award.base_rates` — select by what the rule is). #53811 tried the same move for the exporter and was closed ("this is not the solution"); the accepted #54714 instead *renamed the YAML* so the sort works again, fenced by a lint test — doubling down on names-as-contract. #53561 tried to remove baked multipliers entirely and was closed by its own author ("I think this will cause unwanted side effects, but I don't have a better idea"). We have no agreed doctrine. | |
| ## What is still exposed | |
| 1. **ENG-4326 is unfixed.** Juniors on higher duties in "both-aged" configurations (~3,000 ATOs per #53561's analysis) remain underpaid by cents per hour, amplified by penalty multipliers. Two fix attempts closed; no successor. | |
| 2. **The HD multiplier-direction fallback** still relies on name-sort order — explicitly deferred in #55454 because HIGA's current behaviour depends on it. This is the same accident that produced the $0 bug, one code path over. | |
| 3. **Paycode mappings keyed on multiplier-embedding export names** — any future change to HD multipliers (including fixing ENG-4326) re-triggers the mapping-orphan problem #53561 §9 documented. | |
| 4. **The exporter's `fetch(0)` sampling contract** is enforced only by a lint test on code-name padding. Any template that reintroduces mixed-kind export-name groups with unlucky prefixes recreates ENG-4359. | |
| 5. **Annual wage review** renames every base rate (period infixes) each EOFY — a scheduled, guaranteed perturbation of the name layer. | |
| ## Structural directions (one paragraph each) | |
| **A. Stable rule identity.** Make `templated_rule_id` (already present in template YAML, already suffixed for HD variants) the join key for HD lookups, paycode mappings, and update tracking, demoting names to display strings. This is the root fix — renames become free — but it has the widest blast radius: every name-matching consumer (HD applier, exporter grouping, mapping storage, IFA re-tracking) needs migrating, and historical data is keyed on names. Realistically an incremental program (new consumers use IDs; migrate one consumer per quarter), not a single PR. | |
| **B. Type-predicate selection.** Generalise #55454's applier fix: audit every representative-selection site and make it discriminate by what the rule *is* — `base_rates` vs `multiplier?`, age band, rate period — with name order allowed only as a final tiebreak. Cheap, incremental, and each site fixed is a bug class closed; but names remain the identity layer, so rename-driven join breakage (exposures 3 and 5) is untouched. Note the org has once rejected this direction (#53811) in favour of renaming — adopting B means agreeing that was the wrong call. | |
| **C. Resolve rates at costing time.** Stop baking cross-level ratios into stored multipliers at generation; look up the to-level, age-correct rate when costing the shift (what #53561's "base-swap" did, marker-gated). This fixes ENG-4326's entire class — baked multipliers can't go stale or be miscalibrated if they don't exist — and shrinks the name layer (no multiplier values in export names). But #53561 died on side effects (interpreter's loose HD tag matching, rule regeneration churn, paycode re-keying), so this needs the exposure-by-exposure derisking that PR started, not a fresh attempt from scratch. | |
| **D. Lint containment.** Accept names-as-contract and fence every known ordering/naming assumption with static template tests, extending #54714 and the existing naming-convention suite: assert code-prefix padding, assert no mixed-kind export-name groups, assert HD name-sort invariants per template. Cheapest and shippable this week; genuinely reduces recurrence. But it is tail-chasing formalised — each lint encodes an assumption we've already been burned by, and does nothing for the next assumption nobody has written down yet. | |
| These are not mutually exclusive. A plausible sequence: **D now** (stop the bleeding), **B opportunistically** (each time we touch a selection site), **C to fix ENG-4326** (resuming #53561's derisking), **A as the destination** for new consumers. | |
| ## The open question: ship #55454, or hold for structural? | |
| #55454 is open and QA-approved. It fixes a live customer underpayment ($0-costed shifts) two ways: a type-predicate fallback guard (direction B — uncontroversial) and per-age-band, per-rate-period generation of `- Adult` HD rules with per-period code-name renaming (more of direction "names-as-contract"). | |
| **Ship it:** the customer is waiting, QA passed all five scenarios, and the fallback guard is a strict improvement regardless of any future structural work. The renaming half adds ~20 rules per affected jump — small at this scope. | |
| **Hold it:** the per-band × per-period generation is exactly the approach #53561 rejected at fleet scale (rule-count growth → archive/delete statement timeouts), and it deepens the period-infixed-name coupling that the annual wage review perturbs every year. If direction A or C lands, this half gets rebuilt anyway. | |
| **Honest assessment of the tradeoff:** the two halves separate cleanly. The fallback guard alone fixes the reported timesheet (verified during development — either half alone makes the end-to-end tests pass). A middle path exists: ship the fallback guard now, hold the generation/renaming half for the structural discussion. The cost of the middle path is losing the fast-path (code-name) lookup for `- Adult` overflow — it would resolve via the now-safe fallback instead — plus re-splitting a QA-approved PR. | |
| ## Suggested discussion outcomes | |
| 1. Decide the #55454 question (ship whole / ship fallback-only / hold) — this is time-sensitive. | |
| 2. Agree a doctrine for representative selection (B) so the next #53811-vs-#54714 disagreement doesn't get re-litigated per incident. | |
| 3. Own ENG-4326: it is a known, reproduced junior underpayment with no open fix. If direction C is the answer, resume from #53561's analysis rather than restarting. | |
| 4. If the team wants direction A, scope the first consumer to migrate (paycode mappings are the strongest candidate — #53561 §9 already proved names can't hold that job). |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment