Elapsed unprocessed scheduled changes do not block rate changes
Date: 14-08-2026 Author(s): Jayden Vicarey Source: Team decision during QA of PR #1613 (EM-3870)
Status
accepted
Context
Rate conflict validation compares a proposed standard rate against a projection of the product's rate schedule. Promotions are modelled as a batch of two scheduled_changes rows — a CREATE_UPDATE row that applies the promotional rate and a DELETE row that reverts it — and the scheduler flips completed on each row as it processes them.
PromotionService::resolveStatus classifies a promotion from those completed flags alone. A change row that is not complete is UPCOMING, regardless of its changing_at timestamp. This means a promotion whose window closed days ago but whose rows were never processed still appears as a pending scheduled promotion in the rate schedule.
The question is whether such an elapsed-but-unprocessed window should participate in rate conflict detection and block unrelated rate changes.
Decision Drivers
- Rate conflict validation evaluates the invariant at the instants a promotion is live — it protects against setting a standard rate ≥ a promotional rate during that window
- A scheduler outage is an operational concern, not a validation one
- Blocking unrelated rate changes weeks after a stale window has passed creates a dead end where a product's rate cannot be edited through the UI
- Simplicity: the rate conflict service should not need special handling for stale windows
Considered Options
Treat unprocessed scheduled changes as pending regardless of timestamps (fail-closed)
Keep completion as the sole determinant. An incomplete change row is pending and blocks immediate standard-rate changes even when its window has long since elapsed.
Treat elapsed unprocessed windows as expired (fail-open)
Evaluate the invariant at the instants a promotion is live. If a window's end has already passed, it is treated as expired — its existence does not block rate changes.
Decision Outcome
Chosen: Treat elapsed unprocessed windows as expired.
The rate conflict system evaluates the invariant at the instants a promotion would be live. An immediate standard-rate proposal uses Carbon::now() as its effectiveAt and isInEffectDuring compares that against the promotion window's endsAt. If the window's end has already passed, the proposal is not in effect during that window and produces zero conflicts.
A scheduler outage that leaves stale rows is an operational problem to solve operationally (alerting, manual reconciliation) — not by blocking unrelated rate changes weeks later.
Consequences
Positive
- A product's rate can always be edited, even when stale scheduler rows exist for that product
- The rate conflict service remains a pure function of the schedule and the proposal timestamp — no special staleness logic needed
- Simpler mental model: elapsed windows are over, future windows are guarded
Negative
- If the scheduler breaks and a promotional rate was never applied or never reverted, a subsequent standard-rate change may set a rate that violates the promotional invariant for a window that never actually fired
- Recovery from a scheduler outage requires operational intervention to reconcile product state, rather than the system refusing mutations in the interim
Neutral
- The
PromotionServiceschedule-building logic is unchanged — unprocessed rows still appear inscheduledPromotions. The conflict resolution changes entirely via the temporal comparison inProposedStandardRateData::isInEffectDuring - Related: ADR 0003 — standard rates are permanent until overridden; ADR 0004 — promotions require an existing standard rate
- Characterised by
RateConflictServiceTest(elapsed pending window does not conflict; a rate scheduled after it also does not) andPromotionServiceTest(an unprocessed elapsed promotion is still projected as pending in the schedule, regardless of whether it participates in conflict detection)