What is currently allocated, and to whom.
The ledger is an ordinary immutable value holding
Agenda.Allocation.t/0 records keyed by session. Two
consequences follow from that key, and they are the whole point of
the module:
Releasing is dropping a key. Cancelling a session frees every resource it held, because nothing else was holding them. There is no release step to forget and no free/busy record to go stale — free time is derived by
Agenda.Availability.free/2, never stored.Moving a session is a changeset, not a rewrite.
diff/3reports only what genuinely changed. The naive alternative — release everything, re-acquire it — loses the room to a competing booking in the gap and churns rows that did not move.
The ledger never writes anything. diff/3 returns a plain value for
a persistence layer to apply inside its own transaction.
The loop
iex> boardroom = Agenda.resource("Boardroom", seats: 8)
iex> {:ok, boardroom} = Agenda.open(boardroom, "2026-06-15T09:00:00/2026-06-15T11:00:00")
iex> session =
...> Agenda.session("Review", duration: "PT1H", window: "2026-06-15/2026-06-16")
...> |> Agenda.Session.needs(:room, seats: 8)
iex> ledger = Agenda.Ledger.new()
iex> {:ok, [best | _]} = Agenda.plan(session, [boardroom], busy: Agenda.busy(ledger))
iex> {:ok, ledger} = Agenda.Ledger.allocate(ledger, best)
iex> Agenda.Ledger.count(ledger)
1"Plan against what is already allocated, then allocate the best option."
Summary
Types
One line of a changeset: keep a binding untouched, give one back, or take a new one.
Allocations held against the sessions that hold them.
Functions
Record an arrangement, replacing whatever that session held before.
Rebuild what is allocated as arrangements, ready to pin.
What each resource is already claimed for, shaped for
Agenda.Planner.plan/3's :busy option.
Book one resource over one interval, refusing what it cannot honour.
Turn a session's hold into a firm allocation.
How many allocations the ledger holds.
What would change if session moved to arrangement.
Drop every hold that has lapsed by now.
What session currently holds.
Claim resources tentatively, until until.
Every allocation that is still only a hold.
An empty ledger.
Record one resource over one interval, checking nothing.
Free everything a session was holding.
Free everything held by a whole series.
Every allocation in the ledger, whoever holds it.
Types
@type change() :: {:keep | :release | :allocate, Agenda.Allocation.t()}
One line of a changeset: keep a binding untouched, give one back, or take a new one.
@type t() :: %Agenda.Ledger{ sessions: %{optional(String.t()) => [Agenda.Allocation.t()]} }
Allocations held against the sessions that hold them.
Functions
@spec allocate(t(), Agenda.Arrangement.t(), keyword()) :: {:ok, t()}
Record an arrangement, replacing whatever that session held before.
Allocating is idempotent: allocating the same arrangement twice leaves the ledger identical, because a session's allocations are replaced wholesale rather than appended.
Arguments
ledgeris at/0.arrangementis aAgenda.Arrangement.t/0.
Options
:tagis what the claim was for, as aAgenda.Allocation.tag/0—{:project, "ACME-2026-01"},{:leave, :annual}. Recorded on every allocation the arrangement implies, and grouped on byAgenda.reconcile/3. The default isnil.
Returns
{:ok, ledger}.
Examples
iex> import Tempo.Sigils
iex> boardroom = Agenda.resource("Boardroom")
iex> arrangement = %Agenda.Arrangement{
...> session: "Review",
...> interval: ~o"2026-06-16T10:00:00/2026-06-16T11:00:00",
...> allocations: %{room: [boardroom]}
...> }
iex> {:ok, ledger} = Agenda.Ledger.allocate(Agenda.Ledger.new(), arrangement)
iex> Agenda.Ledger.count(ledger)
1Recording what the time was for:
iex> import Tempo.Sigils
iex> arrangement = %Agenda.Arrangement{
...> session: "Annual leave",
...> interval: ~o"2026-09-14T09:00:00/2026-09-14T17:00:00",
...> allocations: %{consultant: [Agenda.resource("Dana")]}
...> }
iex> {:ok, ledger} =
...> Agenda.Ledger.allocate(Agenda.Ledger.new(), arrangement, tag: {:leave, :annual})
iex> ledger |> Agenda.Ledger.to_list() |> Enum.map(& &1.tag)
[leave: :annual]
@spec arrangements(t(), [Agenda.Resource.t()], keyword()) :: {:ok, [Agenda.Arrangement.t()]} | {:error, Agenda.Infeasible.t()}
Rebuild what is allocated as arrangements, ready to pin.
This is the inverse of allocate/2, and it exists so that
re-arranging a programme can hold its published sessions still:
Agenda.arrange/3 takes Agenda.Arrangement.t/0 values as
:pinned, and these are the ones already booked.
An allocation records a resource by name, so the pool it came from is needed to restore it. A name the pool does not have is an error rather than a stand-in resource: a substitute would default to concurrency 1 and no place, and would then quietly answer capacity and travel questions wrongly.
Arguments
ledgeris at/0.poolis the list ofAgenda.Resource.t/0the allocations were made from.
Options
:onlyis a session name, or a list of them, to rebuild. The default is every session in the ledger.
Returns
{:ok, arrangements}— one per session, ordered by session name; or{:error, t:Agenda.Infeasible.t/0}naming the resources the pool does not have.
Examples
iex> import Tempo.Sigils
iex> boardroom = Agenda.resource("Boardroom")
iex> arrangement = %Agenda.Arrangement{
...> session: "Review",
...> interval: ~o"2026-06-16T10:00:00/2026-06-16T11:00:00",
...> allocations: %{room: [boardroom]}
...> }
iex> {:ok, ledger} = Agenda.Ledger.allocate(Agenda.Ledger.new(), arrangement)
iex> {:ok, [rebuilt]} = Agenda.Ledger.arrangements(ledger, [boardroom])
iex> {rebuilt.session, Map.keys(rebuilt.allocations)}
{"Review", [:room]}
iex> import Tempo.Sigils
iex> boardroom = Agenda.resource("Boardroom")
iex> arrangement = %Agenda.Arrangement{
...> session: "Review",
...> interval: ~o"2026-06-16T10:00:00/2026-06-16T11:00:00",
...> allocations: %{room: [boardroom]}
...> }
iex> {:ok, ledger} = Agenda.Ledger.allocate(Agenda.Ledger.new(), arrangement)
iex> {:error, reason} = Agenda.Ledger.arrangements(ledger, [])
iex> Agenda.Infeasible.message(reason)
"Review cannot be held: Boardroom is allocated but is not in the pool"
@spec busy( t(), keyword() ) :: %{optional(String.t()) => [Tempo.Interval.t()]}
What each resource is already claimed for, shaped for
Agenda.Planner.plan/3's :busy option.
This is what closes the loop: plan against the ledger, allocate the result, and the next plan sees it.
Arguments
ledgeris at/0.
Options
:exceptis a session name, or a list of them, whose allocations are ignored — use it when re-planning a session so it does not collide with the copy of itself it is about to replace, or when pinning sessions inAgenda.arrange/3, which claims what its pins hold and so must not be told about them twice.
Returns
- a map of resource name to the intervals claiming it.
Examples
iex> import Tempo.Sigils
iex> boardroom = Agenda.resource("Boardroom")
iex> arrangement = %Agenda.Arrangement{
...> session: "Review",
...> interval: ~o"2026-06-16T10:00:00/2026-06-16T11:00:00",
...> allocations: %{room: [boardroom]}
...> }
iex> {:ok, ledger} = Agenda.Ledger.allocate(Agenda.Ledger.new(), arrangement)
iex> Agenda.Ledger.busy(ledger) |> Map.keys()
["Boardroom"]
iex> import Tempo.Sigils
iex> boardroom = Agenda.resource("Boardroom")
iex> arrangement = %Agenda.Arrangement{
...> session: "Review",
...> interval: ~o"2026-06-16T10:00:00/2026-06-16T11:00:00",
...> allocations: %{room: [boardroom]}
...> }
iex> {:ok, ledger} = Agenda.Ledger.allocate(Agenda.Ledger.new(), arrangement)
iex> Agenda.Ledger.busy(ledger, except: "Review")
%{}
@spec claim(t(), Agenda.Resource.t(), Agenda.Availability.pattern(), keyword()) :: {:ok, t()} | {:error, term()}
Book one resource over one interval, refusing what it cannot honour.
record/4 records; claim/4 books. The difference is the
difference between a timesheet and a schedule, and both are wanted:
A schedule is a request about the future, so it must be checked. A consultant cannot be booked on a Saturday they do not work, nor twice over the same hour, and finding that out at the moment of booking is the point.
A timesheet is a record of what happened, and the world is not obliged to match the contract. Somebody did work that Saturday. Refusing to write it down would leave the system unable to represent overtime, so
record/4writes down anything andAgenda.reconcile/3is what reports the disagreement afterwards.
Both write to the same ledger, so a booking and a recorded hour cannot double-claim the same person.
Arguments
ledgeris at/0.resourceis theAgenda.Resource.t/0being claimed.intervalis when — a Tempo value or an ISO 8601 string.
Options
:tagis what the claim is for, as aAgenda.Allocation.tag/0.:roleis the role the resource is filling. The default is:resource.:sessionnames the claim, and is whatrelease/2takes. The default is the interval's ISO 8601 form, which is unique per resource per span.
Returns
{:ok, ledger}; or{:error, reason}— a sentence naming the time that could not be claimed and why, when the resource is not open then or is already claimed for part of it.
Examples
iex> import Tempo.Sigils
iex> {:ok, dana} = Agenda.open(Agenda.resource("Dana"), "2026-08-10T09:00:00/2026-08-10T17:00:00")
iex> {:ok, ledger} = Agenda.Ledger.claim(Agenda.Ledger.new(), dana, ~o"2026-08-10T09:00:00/2026-08-10T12:00:00")
iex> Agenda.Ledger.count(ledger)
1Booking the same hour twice is refused rather than recorded:
iex> import Tempo.Sigils
iex> {:ok, dana} = Agenda.open(Agenda.resource("Dana"), "2026-08-10T09:00:00/2026-08-10T17:00:00")
iex> {:ok, ledger} = Agenda.Ledger.claim(Agenda.Ledger.new(), dana, ~o"2026-08-10T09:00:00/2026-08-10T12:00:00")
iex> {:error, reason} = Agenda.Ledger.claim(ledger, dana, ~o"2026-08-10T11:00:00/2026-08-10T13:00:00")
iex> reason
"Dana is already claimed for 2026Y8M10DT11H0M0S/T12H0M0S"And so is time the resource does not work:
iex> import Tempo.Sigils
iex> {:ok, dana} = Agenda.open(Agenda.resource("Dana"), "2026-08-10T09:00:00/2026-08-10T17:00:00")
iex> {:error, reason} = Agenda.Ledger.claim(Agenda.Ledger.new(), dana, ~o"2026-08-15T09:00:00/2026-08-15T12:00:00")
iex> reason
"Dana is not open for 2026Y8M15DT9H0M0S/T12H0M0S"
Turn a session's hold into a firm allocation.
Arguments
ledgeris at/0.sessionis the session's name.
Returns
{:ok, ledger}. Confirming a session that holds nothing, or one already firm, is a no-op.
Examples
iex> import Tempo.Sigils
iex> boardroom = Agenda.resource("Boardroom")
iex> arrangement = %Agenda.Arrangement{
...> session: "Review",
...> interval: ~o"2026-06-16T10:00:00/2026-06-16T11:00:00",
...> allocations: %{room: [boardroom]}
...> }
iex> {:ok, ledger} = Agenda.Ledger.hold(Agenda.Ledger.new(), arrangement,
...> until: "2026-06-15T10:15:00")
iex> {:ok, ledger} = Agenda.Ledger.confirm(ledger, "Review")
iex> Agenda.Ledger.holds(ledger)
[]
@spec count(t()) :: non_neg_integer()
How many allocations the ledger holds.
Arguments
ledgeris at/0.
Returns
- the count.
Examples
iex> Agenda.Ledger.count(Agenda.Ledger.new())
0
@spec diff(t(), String.t(), Agenda.Arrangement.t()) :: [change()]
What would change if session moved to arrangement.
Only genuine movement is reported. A binding the new arrangement
still wants is :keep, never a :release followed by an
:allocate — that pair would hand the resource to a competing
booking in the gap between them, and churn rows that never moved.
Arguments
ledgeris at/0.sessionis the session's name.arrangementis theAgenda.Arrangement.t/0it should move to.
Returns
- a list of
change/0, ordered:keep, then:release, then:allocate.
Examples
Re-planning a session to exactly where it already is changes nothing:
iex> import Tempo.Sigils
iex> boardroom = Agenda.resource("Boardroom")
iex> arrangement = %Agenda.Arrangement{
...> session: "Review",
...> interval: ~o"2026-06-16T10:00:00/2026-06-16T11:00:00",
...> allocations: %{room: [boardroom]}
...> }
iex> {:ok, ledger} = Agenda.Ledger.allocate(Agenda.Ledger.new(), arrangement)
iex> Agenda.Ledger.diff(ledger, "Review", arrangement) |> Enum.map(&elem(&1, 0))
[:keep]
@spec expire(t(), Agenda.Availability.pattern()) :: {:ok, t()} | {:error, term()}
Drop every hold that has lapsed by now.
This is the only function here that has anything to do with the
passing of time, and it takes the moment as an argument rather than
reading a clock. That is deliberate. busy/2 is called inside
Agenda.plan/3 and Agenda.arrange/3; if it consulted the
system clock, arranging the same programme twice would give
different answers as holds lapsed underneath it, and the ledger
would stop being a value you can reason about. Refusing to guess the
time is the same discipline as refusing to guess an unmeasured
journey.
A hold lapses when now has reached its expiry — a hold good
until ten past is gone at ten past.
Arguments
ledgeris at/0.nowis the current moment — a Tempo value or an ISO 8601 string.
Returns
{:ok, ledger}with lapsed holds removed; or{:error, reason}whennowcannot be read as a moment.
Examples
iex> import Tempo.Sigils
iex> boardroom = Agenda.resource("Boardroom")
iex> arrangement = %Agenda.Arrangement{
...> session: "Review",
...> interval: ~o"2026-06-16T10:00:00/2026-06-16T11:00:00",
...> allocations: %{room: [boardroom]}
...> }
iex> {:ok, ledger} = Agenda.Ledger.hold(Agenda.Ledger.new(), arrangement,
...> until: "2026-06-15T10:15:00")
iex> {:ok, still_held} = Agenda.Ledger.expire(ledger, "2026-06-15T10:10:00")
iex> Agenda.Ledger.count(still_held)
1
iex> {:ok, lapsed} = Agenda.Ledger.expire(ledger, "2026-06-15T10:15:00")
iex> Agenda.Ledger.count(lapsed)
0
@spec for_session(t(), String.t()) :: [Agenda.Allocation.t()]
What session currently holds.
Arguments
ledgeris at/0.sessionis the session's name.
Returns
- the session's allocations, or
[]when it holds nothing.
Examples
iex> Agenda.Ledger.for_session(Agenda.Ledger.new(), "Review")
[]
@spec hold(t(), Agenda.Arrangement.t(), keyword()) :: {:ok, t()} | {:error, term()}
Claim resources tentatively, until until.
A hold is what a booking page takes while someone finds their card. It occupies the resource exactly as an allocation does — that is the point, and it is why holds live in the ledger rather than in a persistence layer. Availability is derived on every call from what the ledger holds, so a hold the ledger cannot see is a hold nobody subtracts, and two people book the same room.
Nothing expires on its own. expire/2 is what advances time, and
until it is called a hold is simply a claim. That keeps the ledger a
value: arranging the same programme twice gives the same answer,
because no function here reads a clock.
Arguments
ledgeris at/0.arrangementis aAgenda.Arrangement.t/0.
Options
:untilis when the hold lapses — a Tempo value or an ISO 8601 string. Required.:tagis what the claim was for, as aAgenda.Allocation.tag/0. A hold occupies a resource exactly as a booking does, so it carries the same tag. The default isnil.
Returns
{:ok, ledger}; or{:error, reason}when:untilcannot be read as a moment.
Examples
iex> import Tempo.Sigils
iex> boardroom = Agenda.resource("Boardroom")
iex> arrangement = %Agenda.Arrangement{
...> session: "Review",
...> interval: ~o"2026-06-16T10:00:00/2026-06-16T11:00:00",
...> allocations: %{room: [boardroom]}
...> }
iex> {:ok, ledger} = Agenda.Ledger.hold(Agenda.Ledger.new(), arrangement,
...> until: "2026-06-15T10:15:00")
iex> Agenda.Ledger.holds(ledger) |> Enum.map(& &1.session)
["Review"]
@spec holds(t()) :: [Agenda.Allocation.t()]
Every allocation that is still only a hold.
Arguments
ledgeris at/0.
Returns
- the held allocations, ordered as
to_list/1orders them.
Examples
iex> Agenda.Ledger.holds(Agenda.Ledger.new())
[]
@spec new() :: t()
An empty ledger.
Returns
- an empty
t/0.
Examples
iex> Agenda.Ledger.count(Agenda.Ledger.new())
0
@spec record(t(), Agenda.Resource.t(), Agenda.Availability.pattern(), keyword()) :: {:ok, t()} | {:error, term()}
Record one resource over one interval, checking nothing.
The unchecked half of the pair claim/4 completes. Same arguments,
same options, same ledger — and no opinion about whether the time
was available.
That is not a weaker claim/4; it answers a different question. A
schedule is a request about the future and must be checked before it
is accepted. A timesheet is a record of what already happened, and
the world is not obliged to match the contract: somebody worked that
Saturday whether or not their contract says they work Saturdays, and
a system that refuses to write it down cannot represent overtime.
So claim/4 refuses at the door and record/4 writes it down —
leaving Agenda.reconcile/3 to report the disagreement afterwards,
which is the only place that can weigh it against a whole period.
Arguments
ledgeris at/0.resourceis theAgenda.Resource.t/0whose time is recorded.intervalis when — a Tempo value or an ISO 8601 string.
Options
As claim/4: :tag, :role and :session.
Returns
{:ok, ledger}; or{:error, reason}whenintervalcannot be read as a span. Never for a reason of availability — that isclaim/4's business.
Examples
Time outside the contract is recorded rather than refused:
iex> import Tempo.Sigils
iex> {:ok, dana} = Agenda.open(Agenda.resource("Dana"), "2026-08-10T09:00:00/2026-08-10T17:00:00")
iex> {:error, _refused} = Agenda.Ledger.claim(Agenda.Ledger.new(), dana, ~o"2026-08-15T09:00:00/2026-08-15T12:00:00")
iex> {:ok, ledger} = Agenda.Ledger.record(Agenda.Ledger.new(), dana, ~o"2026-08-15T09:00:00/2026-08-15T12:00:00")
iex> Agenda.Ledger.count(ledger)
1And it is still a claim on the resource, so a booking cannot land on top of it:
iex> import Tempo.Sigils
iex> {:ok, dana} = Agenda.open(Agenda.resource("Dana"), "2026-08-10T09:00:00/2026-08-10T17:00:00")
iex> {:ok, ledger} = Agenda.Ledger.record(Agenda.Ledger.new(), dana, ~o"2026-08-10T09:00:00/2026-08-10T12:00:00")
iex> {:error, reason} = Agenda.Ledger.claim(ledger, dana, ~o"2026-08-10T10:00:00/2026-08-10T11:00:00")
iex> reason
"Dana is already claimed for 2026Y8M10DT10H0M0S/T11H0M0S"
Free everything a session was holding.
Arguments
ledgeris at/0.sessionis the session's name.
Returns
{:ok, ledger}. Releasing a session that holds nothing is a no-op, so this is safe to call twice.
Examples
iex> import Tempo.Sigils
iex> boardroom = Agenda.resource("Boardroom")
iex> arrangement = %Agenda.Arrangement{
...> session: "Review",
...> interval: ~o"2026-06-16T10:00:00/2026-06-16T11:00:00",
...> allocations: %{room: [boardroom]}
...> }
iex> {:ok, ledger} = Agenda.Ledger.allocate(Agenda.Ledger.new(), arrangement)
iex> {:ok, ledger} = Agenda.Ledger.release(ledger, "Review")
iex> Agenda.Ledger.count(ledger)
0
Free everything held by a whole series.
The occurrences of a repeating session are separate sessions sharing a series name, so cancelling the run is one call rather than a loop the caller has to get right.
Arguments
ledgeris at/0.seriesis the series name — the original session's name, before it was expanded.
Options
:fromreleases only the occurrences starting at or after this point, a Tempo value or ISO 8601 string. Cancelling the rest of a term should not unpick the sessions already held.
Returns
{:ok, ledger}; or{:error, reason}when:fromcannot be read.
Examples
iex> Agenda.Ledger.release_series(Agenda.Ledger.new(), "Stand-up")
{:ok, Agenda.Ledger.new()}
@spec to_list(t()) :: [Agenda.Allocation.t()]
Every allocation in the ledger, whoever holds it.
Arguments
ledgeris at/0.
Returns
- the allocations, ordered by session then role then resource so the listing is stable.
Examples
iex> Agenda.Ledger.to_list(Agenda.Ledger.new())
[]