Skip to content

Instantly share code, notes, and snippets.

@juike
Created May 18, 2026 12:55
Show Gist options
  • Select an option

  • Save juike/1be17c200cda2ba88d559251b075b333 to your computer and use it in GitHub Desktop.

Select an option

Save juike/1be17c200cda2ba88d559251b075b333 to your computer and use it in GitHub Desktop.
HG-3372 iteration 1 — revised plan

HG-3372 — Iteration 1 plan, revised

Revision of the iteration 1 plan from the original design doc. Changes are driven by a critique pass; each addresses a specific weakness in the original plan.

Revised iteration plan

Iteration 0 (expanded — now three sub-steps, not one)

0a. Behavior-capture specs. As originally scoped.

0b. Coverage audit. Before iteration 1 starts, produce an explicit matrix of production billing variations (frequency × autopay × rate_type × OTP-presence × custom-invoicing-day × currency-count × payment-provider) and confirm each cell has at least one spec in 0a. Cells with zero coverage block iteration 1 for those paths.

0c. Validator in shadow mode (was deferred to iteration 2; promoted). Ship one check — MonthlyTotalCheck — running nightly over the last 90 days of cycles. Drive shadow disagreements to zero before iteration 1 touches any production code. Any disagreement is either a pre-existing bug or a hole in the Validator's contract reading; both need to be found and fixed before the refactor, not after. This converts iteration 1's "behavior unchanged, trust the specs" into "behavior unchanged, two independent oracles agree."

Iteration 1 — revised ticket sequence

Ticket 1: Extract DueDatePolicy and PayoutDatePolicy. (Unchanged.) Add an explicit decision in the PR description: persisted columns on Cycle remain authoritative for historical reads; policies are only consulted at cycle creation. Document the recompute rule: contract change does not retroactively rewrite past cycles. Closes the anemic-domain-model ambiguity at the moment we introduce it, instead of leaving it for later.

Ticket 2: Extract FrequencyPolicy. (Unchanged.)

Ticket 3: Extract ApprovalPolicy. (Unchanged.)

Ticket 4 (was the load-bearing PR — now split into three):

  • 4a. Introduce WorkerGroup, StrategyResolver, and the new strategy classes alongside Cycle::Processor. No call-site changes. New code exists but is unreachable. Renamed: HourlyTimesheetCycle and FixedRateCycle — drop "Legacy" since these are how every hourly/fixed client bills today and for the foreseeable future. Naming describes behavior, not era.
  • 4b. Dual-write / shadow execution. Behind a feature flag (billing_strategy_v2), run the new strategies in parallel with Cycle::Processor for a configurable client allowlist. The Validator from 0c watches both outputs; any divergence between old and new is a regression we catch before cutover. Run for a minimum window (suggestion: 2 full billing cycles per frequency, i.e. 2 months).
  • 4c. Cutover and removal. Flip the flag globally, delete Cycle::Processor, remove the dual-write scaffolding. This PR is small and mostly deletes code, because the risk was retired in 4b.

This redistributes the original ticket 4's risk across three PRs with a real shadow window in the middle, rather than concentrating it in one cutover.

Ticket 5: Daily-job dispatch. (Renumbered from old 5.) Same content, but now lands after the cutover in 4c so the strategies it dispatches to are the only billing path.

Ticket 6: Reactive triggers. (Unchanged from original 6.) Add a precondition: enumerate the eight triggers in the ticket description before opening it — the original said "to be inventoried at pickup," which hides the scope.

Structural changes beyond ticket reshuffling

Rename across the board: drop "Legacy." HourlyTimesheetCycle, FixedRateCycle, ClientStrategy. The cost of the rename is paid once now; the cost of the misleading name compounds for years.

Acknowledge WorkerGroup's scope explicitly. Add a paragraph to the doc stating: dispatch happens at two scopes — WorkerGroup (rate_type, frequency, autopay, currency) and Client (invoicing strategy, OTP routing). Iteration 1 only formalizes the group-level dispatch; iteration 2's invoicing axes are client-level. This kills the "single dispatch unit" claim now, before iteration 2 quietly breaks it.

Forcing function against subclass proliferation. Add a written rule to the doc: a new strategy or policy with fewer than three differentiating method overrides should be a parameterization of an existing one, not a new subclass. Without this, the wizard end-state stays aspirational while engineers keep reaching for class FooStrategy < Bar.

Operational appendix. Add a short section to the doc covering: feature flag strategy (one per ticket where applicable), Sidekiq drain plan before Cycle::Processor removal, deploy-vs-migration ordering (no migrations in iteration 1 anyway, so cheap), rollback procedure (flag flip, no schema work).

What gets removed from iteration 2's scope

  • Validator (now in 0c).

What stays deliberately out

Schema changes, new entities (BillableWork), the four-axis composite. Same as before.


Net effect: iteration 1 grows by roughly one ticket (the Validator in 0c) and one split ticket (4a/4b/4c instead of 4). In exchange you get an independent invariant oracle running before the refactor begins, a real shadow window for the load-bearing change, and naming that won't need re-doing in 18 months. The cost is ~2–3 extra weeks; the value is "behavior unchanged" being a verifiable claim rather than a hope.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment