How much of a resource may — or must — be claimed over a period.
A limit is a budget on a stretch of calendar. It is not concurrency: concurrency asks how many claims may overlap at one instant, a limit asks how much falls inside a day, a week or a month, however far apart the claims sit.
Two measures
A limit counts either claims or time, and the difference is the difference between a roster and a timesheet:
Agenda.resource("Dana", limits: [day: 3, week: 12])
Agenda.resource("Dana", limits: [day: ~o"PT7H36M", week: ~o"PT38H"])The first says "at most three engagements a day". The second says "at most seven hours thirty-six minutes a day" — the same period vocabulary and the same ledger, measured in duration rather than cardinality. Eight open hours is not eight jobs, and neither is it eight hours of billable work.
Ceilings and floors
An integer or a duration on its own is a ceiling. A floor is written explicitly, and both ends may be given at once:
limits: [week: [at_least: ~o"PT38H", at_most: ~o"PT45H"]]"At least a full week, and no more than forty-five hours."
Only ceilings constrain the search, and this is a real asymmetry rather than an omission. A ceiling prunes: a candidate that would breach it can be rejected the moment it is considered, because nothing added later can bring the total back down. A floor cannot be used that way — a partial layout is supposed to be under the floor, and rejecting it would reject every layout before the last placement.
So a floor is a completion condition, not a placement condition.
Agenda.arrange/3 and Agenda.plan/3 enforce ceilings and ignore
floors; Agenda.reconcile/3 checks both, because it is the function
that looks at a finished period and asks whether it adds up.
Summary
Functions
How a period's claims breach a limit, or nil when they do not.
Which day, week or month a moment falls in.
A limit's ceiling as a plain integer in the unit the limit measures.
What a count and a duration amount to, in the unit limit measures.
Read a resource's :limits keyword list into limits.
Read a resource's :limits, raising on anything malformed.
true when count claims totalling duration sit within the
limit's ceiling.
What a set of claims comes to, as a count and a duration.
Types
@type measure() :: {:count, pos_integer()} | {:duration, Tempo.Duration.t()}
What a limit counts — claims, or time.
@type t() :: %Agenda.Limit{ at_least: measure() | nil, at_most: measure() | nil, period: :day | :week | :month }
A budget over one period.
at_most is a ceiling and constrains the search. at_least is a
floor and is checked only by Agenda.reconcile/3. Either may be
nil, but not both.
Functions
@spec breach(t(), non_neg_integer(), Tempo.Duration.t()) :: {:over, measure()} | {:under, measure()} | nil
How a period's claims breach a limit, or nil when they do not.
Both ends are checked, so this is the function that sees a floor.
Arguments
limitis at/0.countis how many claims fall in the period.durationis what they total, as aTempo.Duration.t/0.
Returns
nilwhen the claims satisfy the limit; or{:over, measure}naming the ceiling that was exceeded; or{:under, measure}naming the floor that was not reached.
Examples
iex> import Tempo.Sigils
iex> [limit] = Agenda.Limit.parse!(week: [at_least: ~o"PT38H"])
iex> Agenda.Limit.breach(limit, 5, ~o"PT30H")
{:under, {:duration, ~o"PT38H"}}
iex> import Tempo.Sigils
iex> [limit] = Agenda.Limit.parse!(week: [at_least: ~o"PT38H"])
iex> Agenda.Limit.breach(limit, 5, ~o"PT38H")
nil
Which day, week or month a moment falls in.
A bucket is {year, month, day} for every period — the components of
the period's first moment, with day absent for a month. Uniform by
construction, and never compared across periods.
Each is Tempo.trunc/2 to that period, reduced to plain components:
the day, the day its week begins on, or its month. The boundary is
therefore the calendar's own, not an arbitrary window measured from
the first claim — and which calendar is the value's own. A week
runs from whichever day that calendar starts its weeks on, so a value
in a Sunday-start calendar buckets its Sundays with the following
Monday where an ISO value buckets them with the preceding one.
Reading the convention off the value is what keeps a weekly contract
counting the seven days its holder actually works.
Arguments
momentis aTempo.t/0.periodis:day,:weekor:month.
Returns
an opaque bucket key, equal for two moments in the same period; or
:undatedwhen the moment does not carry a full date, or the period is not one this module knows. Everything undated shares one bucket, which keeps a limit conservative rather than unenforced.
Examples
iex> import Tempo.Sigils
iex> Agenda.Limit.bucket(~o"2026-06-16T10:00:00", :day)
{2026, 6, 16}
iex> import Tempo.Sigils
iex> Agenda.Limit.bucket(~o"2026-06-16T10:00:00", :month)
{2026, 6, nil}
iex> import Tempo.Sigils
iex> # Tuesday and Thursday of one week share its Monday.
iex> Agenda.Limit.bucket(~o"2026-06-16T10:00:00", :week) ==
...> Agenda.Limit.bucket(~o"2026-06-18T10:00:00", :week)
true
@spec ceiling(t()) :: non_neg_integer() | nil
A limit's ceiling as a plain integer in the unit the limit measures.
Counts are themselves; durations are whole seconds. This is what a solver needs — a constraint model has integers and no opinion about what they mean — and keeping the conversion here means the bridge and the built-in search cannot disagree about the unit.
Arguments
limitis at/0.
Returns
- the ceiling as a non-negative integer, or
nilwhen the limit sets no ceiling.
Examples
iex> [limit] = Agenda.Limit.parse!(day: 3)
iex> Agenda.Limit.ceiling(limit)
3
iex> import Tempo.Sigils
iex> [limit] = Agenda.Limit.parse!(day: ~o"PT8H")
iex> Agenda.Limit.ceiling(limit)
28800
iex> import Tempo.Sigils
iex> [limit] = Agenda.Limit.parse!(week: [at_least: ~o"PT38H"])
iex> Agenda.Limit.ceiling(limit)
nil
@spec measure(t(), non_neg_integer(), Tempo.Duration.t()) :: non_neg_integer()
What a count and a duration amount to, in the unit limit measures.
The companion to ceiling/1: both sides of a comparison expressed as
integers in the same unit.
Arguments
limitis at/0.countis how many claims fall in the period.durationis what they total, as aTempo.Duration.t/0.
Returns
- a non-negative integer — the count itself for a limit measuring claims, whole seconds for one measuring time.
Examples
iex> import Tempo.Sigils
iex> [limit] = Agenda.Limit.parse!(day: 3)
iex> Agenda.Limit.measure(limit, 2, ~o"PT8H")
2
iex> import Tempo.Sigils
iex> [limit] = Agenda.Limit.parse!(day: ~o"PT8H")
iex> Agenda.Limit.measure(limit, 2, ~o"PT3H")
10800
Read a resource's :limits keyword list into limits.
Arguments
limitsis a keyword list keyed by period —:day,:weekor:month. Each value is a count, a duration, or a keyword list carrying:at_mostand/or:at_least.
Returns
{:ok, limits}in the order given; or{:error, reason}naming the period that could not be read.
Examples
iex> {:ok, [limit]} = Agenda.Limit.parse(week: 5)
iex> {limit.period, limit.at_most, limit.at_least}
{:week, {:count, 5}, nil}
iex> import Tempo.Sigils
iex> {:ok, [limit]} = Agenda.Limit.parse(day: ~o"PT7H36M")
iex> limit.at_most
{:duration, ~o"PT7H36M"}
iex> import Tempo.Sigils
iex> {:ok, [limit]} = Agenda.Limit.parse(week: [at_least: ~o"PT38H", at_most: ~o"PT45H"])
iex> {limit.at_least, limit.at_most}
{{:duration, ~o"PT38H"}, {:duration, ~o"PT45H"}}
iex> Agenda.Limit.parse(fortnight: 5)
{:error, "unknown limit period :fortnight — expected :day, :week or :month"}
Read a resource's :limits, raising on anything malformed.
Agenda.Resource.new/2 returns a resource rather than a tuple, so a
limit it cannot read has nowhere to go but an exception. That is the
right outcome: a limit silently dropped is a contract silently not
enforced.
Arguments
limitsis as forparse/1.
Returns
- the limits; or raises
ArgumentError.
Examples
iex> [limit] = Agenda.Limit.parse!(day: 1)
iex> limit.at_most
{:count, 1}
@spec permits?(t(), non_neg_integer(), Tempo.Duration.t()) :: boolean()
true when count claims totalling duration sit within the
limit's ceiling.
A limit with no ceiling always permits, which is what makes a floor-only limit invisible to the search.
Arguments
limitis at/0.countis how many claims fall in the period.durationis what they total, as aTempo.Duration.t/0.
Returns
trueorfalse.
Examples
iex> import Tempo.Sigils
iex> [limit] = Agenda.Limit.parse!(day: ~o"PT8H")
iex> {Agenda.Limit.permits?(limit, 1, ~o"PT7H"), Agenda.Limit.permits?(limit, 1, ~o"PT9H")}
{true, false}
iex> import Tempo.Sigils
iex> [limit] = Agenda.Limit.parse!(week: [at_least: ~o"PT38H"])
iex> Agenda.Limit.permits?(limit, 99, ~o"PT99H")
true
@spec sum(Tempo.IntervalSet.t() | [Tempo.Interval.t() | Agenda.Allocation.t()]) :: {non_neg_integer(), Tempo.Duration.t()}
What a set of claims comes to, as a count and a duration.
Named for Tempo.Duration.sum/1, and both measures are computed in
one pass because a limit may be expressed either way and the caller
does not know which until it looks at the limit.
Arguments
claimsis aTempo.IntervalSet.t/0, a list ofTempo.Interval.t/0, or a list ofAgenda.Allocation.t/0. Agenda knows Tempo's types intimately, so a caller holding a set should not have to take it apart first.
Returns
{count, duration}. A claim with no measurable length — unbounded, or a recurrence — contributes nothing to the duration and still contributes one to the count. That keeps a limit conservative rather than silently unenforced.
Examples
An interval set, taken whole:
iex> import Tempo.Sigils
iex> {:ok, set} = Tempo.IntervalSet.new([
...> ~o"2026-06-16T09:00:00/2026-06-16T12:00:00",
...> ~o"2026-06-16T13:00:00/2026-06-16T17:00:00"
...> ])
iex> {count, duration} = Agenda.Limit.sum(set)
iex> {count, Tempo.Duration.to_unit(duration, :hour)}
{2, {:ok, 7.0}}A list of intervals:
iex> import Tempo.Sigils
iex> {count, duration} = Agenda.Limit.sum([~o"2026-06-16T09:00:00/2026-06-16T12:00:00"])
iex> {count, Tempo.Duration.to_unit(duration, :hour)}
{1, {:ok, 3.0}}A list of allocations, summed by their intervals:
iex> import Tempo.Sigils
iex> allocations = [
...> %Agenda.Allocation{interval: ~o"2026-06-16T09:00:00/2026-06-16T12:00:00"},
...> %Agenda.Allocation{interval: ~o"2026-06-16T13:00:00/2026-06-16T17:00:00"}
...> ]
iex> {count, duration} = Agenda.Limit.sum(allocations)
iex> {count, Tempo.Duration.to_unit(duration, :hour)}
{2, {:ok, 7.0}}