- Status: Accepted (2026-08-23)
- Implements: roadmap M4 (foundation); AD4; extends ADR-0005, ADR-0016
ADR-0005 decided a DueWork index replaces the prototype's global EventBridge cadences, scanned
by a dispatcher that fans bounded batches into priority-lane queues with per-user concurrency
caps. That ADR fixed the shape; this one implements the scanning and fairness logic as
dependency-free Python so the fairness property — no noisy neighbor starves anyone else — is
unit- and property-testable without SQS, Lambda, or DynamoDB in the loop. Everything downstream
(the actual queue technology, the DynamoDB GSI) is an adapter around this logic, not a rewrite of
it, matching how InMemoryStore already stands in for the future DynamoDB adapter (ADR-0016).
The due-work index is inherently cross-tenant: a fair dispatcher must see every user's next-due
time in one scan. Every other surface in this codebase (TenantRepository) is deny-by-default
and single-tenant per call — deliberately incompatible with that requirement. Bolting a scan
method onto TenantRepository would have meant either breaking its isolation invariant for
everyone or special-casing one caller inside the file the adversarial suite treats as a security
boundary (ADR-0016).
DueWorkIndexis its own storage surface, not aentities.ENTITIESmember and not aTenantRepositoryconsumer. Writes (schedule,unschedule,get) still require a validatedTenantContext, same as everywhere else in the codebase. Reads for the dispatcher (due_before) are deliberately cross-tenant — the "operator GSI" roadmap Appendix B describes — and documented as the narrow, intentional exception it is. The row shape itself (DueWorkEntry, a fixed dataclass) structurally cannot carry posting content, credentials, or settings, so the exception cannot leak more than scheduling metadata by construction.- Cadence is a pure function (
cadence.next_interval_seconds) over a policy and a miss-streak counter: a hit snaps back to the tier's base interval, a miss backs off multiplicatively up to a clamp. Named presets (TIER_HOT/WARM/COLD,DIGEST_WEEKLY) replacepoll_tiering.yaml's global constants;due_work.advancewires the function to one entry without any caller touching a clock except by passingnowexplicitly. - Dispatch is round-robin-fair per lane, priority-ordered across lanes. Within one lane
(
free/plus/pro),_drain_lane_fairgrants each distinct user a turn before any user gets a second item, so a cap smaller than one user's backlog still reaches everyone else that cycle. Across lanes, aglobal_batch_limit(the real constraint: one dispatcher invocation's Lambda budget) preferspro, thenplus, thenfree— but only after each lane has already applied its own per-user fairness, so paying more buys priority, never someone else's fair share within a cheaper lane. - Shedding is visible, not silent.
DispatchBatch.shed_user_idsnames every user who had due work this cycle but didn't get all of it dispatched — whether capped by their own lane's per-user limit or dropped by the global cap. Nothing is deleted; shed entries simply remain due and are picked up next cycle.
- The DynamoDB GSI adapter for
DueWorkIndex(M4 infra work) only needs to reproducedue_before's ordering contract; the fairness algorithm above does not change. Dispatchertakeslane_foras a plain callable rather than importingcontrol.plansdirectly, soscheduling/stays decoupled from the control plane; production wiring isplan_lane(entitlements.plan(ctx).plan_id).- A future queue-depth or worker-health signal can feed
LaneCapacity.per_cycle_limitper cycle without touching the fairness algorithm. shed_user_idsis the hook for the noisy-neighbor alarm the roadmap's cost-safety workstream calls for; nothing further is needed to observe it, only to alarm on it.