ADR-139: Duration reset windows anchored to first use¶
Status: Accepted (amended by ADR-149: a session top-up opens a window; by ADR-151: a bypassed request does not anchor one) Date: 2026-09-15 Issue: #597 Related: ADR-137, ADR-138, ADR-140, ADR-141
Context¶
ADR-137 gives a limit two ways to recover: a drip, or a reset that restores the balance in one lump. ADR-138 decided that a reset is named by a cron expression, so every entity sharing a schedule resets at the same wall-clock instant. That is what a billing period needs.
A session cap needs the other shape: "10,000 tokens, and your window resets five hours after you
first used it". Two entities that first call at 09:00 and 14:30 have windows ending at 14:00 and
19:30. This is the behaviour of widely deployed allowance systems, and a reader who sees
"five-hour window" will assume it. A duration cannot be written in cron, so it needs a second
field rather than a second reading of the first. Feasibility, costing and the full alternatives
are in docs/plans/2026-09-15-rolling-session-windows-analysis.md and
docs/plans/2026-09-15-session-quotas-plan.md.
This record lifts ADR-138's deferral of duration-based windows; #597 is the request ADR-138's
rejected "support both, select per limit" alternative waited for. ADR-138's own decision — that
reset_schedule names only fixed calendar windows — is unaffected: this is a new mechanism, not
a reinterpretation of reset_schedule. How an entity's shards agree on one window is
ADR-140; when such a limit may be stored at all is
ADR-141.
Decision¶
A limit may carry reset_after, a duration quota (ADR-137) that must not also carry
reset_schedule, and its balance must be restored whole when its window ends. The window must
open at the entity's first admitted, committed request after the previous window ended
(idle-restarting, never tiling), must be anchored per entity so a cascade parent never follows
its child, and its start must be stored per limit on the bucket item, never derived from vu.
A refill_amount of zero is therefore accepted when exactly one of reset_schedule or
reset_after is present; this supersedes ADR-137's zero-rate clause, which named
reset_schedule alone.
Consequences¶
Positive: - Entities do not reset simultaneously, which removes the thundering herd ADR-138 records as its strongest negative. The spread is a property of the anchor, not of added jitter. - Evaluation is cheaper than the calendar form: no cron parse, no timezone database, no daylight-saving handling and no boundary scan. The reset instant is read off the item. - The TTL recovery horizon is the window length exactly, with no rounding and no clock. - A rejected request writes nothing, so hammering an exhausted quota never moves its anchor. The one exception is ADR-145's: a rejected request that also moves quota tokens onto a shard it creates or seeds commits that move with nothing consumed, and a window it opened is anchored with it — at most once per such shard. - The per-acquire cost is unchanged; the speculative fast path reads no config.
Negative:
- The window is not recoverable from the clock, so losing every shard of a bucket loses it.
ADR-138 records this as its strongest surviving objection, and idle-restarting answers it: an
item swept by TTL means the entity was idle for a multiple of the window, so a fresh window on
the next request is exactly the specified behaviour. Only a purge or manual delete remains,
which loses the balance too and is not specific to this shape.
- A client or Lambda predating this record cannot read a reset_after limit; what that costs and
how the stack is protected is ADR-141.
- A cascade that creates a parent shard pays for the parent's own window read, because parent
and child anchors are independent.
Alternatives Considered¶
Duration windows expressed in cron¶
Rejected because: cron names wall-clock instants and cannot express "five hours after you started".
One field, interpreted as cron or as a duration depending on its content¶
Rejected because: it doubles the semantics of every reader behind a value whose meaning is discovered by parsing it.
Read the window end off vu¶
Rejected because: vu is also the limit-change fan-out's marker and the aggregator's staleness
pin, so every set_limits() would read as an elapsed window and restore every balance.
Tiling windows from a fixed start instant, projected forward in multiples¶
Rejected because: it needs the same anchor and cannot express "go idle long enough and your window restarts".
A per-entity offset into a fixed grid, derived from the entity id¶
Rejected because: a new entity's first window is whatever fragment of its grid cell remains, and the boundaries are undiscoverable from the config.
Anchor on the entity's #META record¶
Rejected because: window state is per (entity, resource, limit), so #META would grow unbounded
attributes and become a write hot spot at every rollover.
Encode the duration as a token inside rsched¶
Rejected because: decode_reset rejects unknown tags by design, and a token would make every
stored reset conditionally a cron.