Agenda.Ledger (Agenda v0.1.0)

Copy Markdown View Source

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/3 reports 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.

t()

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

change()

@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.

t()

@type t() :: %Agenda.Ledger{
  sessions: %{optional(String.t()) => [Agenda.Allocation.t()]}
}

Allocations held against the sessions that hold them.

Functions

allocate(ledger, arrangement, options \\ [])

@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

Options

  • :tag is what the claim was for, as a Agenda.Allocation.tag/0{:project, "ACME-2026-01"}, {:leave, :annual}. Recorded on every allocation the arrangement implies, and grouped on by Agenda.reconcile/3. The default is nil.

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)
1

Recording 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]

arrangements(ledger, pool, options \\ [])

@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

Options

  • :only is 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"

busy(ledger, options \\ [])

@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

  • ledger is a t/0.

Options

  • :except is 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 in Agenda.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")
%{}

claim(ledger, resource, interval, options \\ [])

@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/4 writes down anything and Agenda.reconcile/3 is 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

  • ledger is a t/0.

  • resource is the Agenda.Resource.t/0 being claimed.

  • interval is when — a Tempo value or an ISO 8601 string.

Options

  • :tag is what the claim is for, as a Agenda.Allocation.tag/0.

  • :role is the role the resource is filling. The default is :resource.

  • :session names the claim, and is what release/2 takes. 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)
1

Booking 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"

confirm(ledger, session)

@spec confirm(t(), String.t()) :: {:ok, t()}

Turn a session's hold into a firm allocation.

Arguments

  • ledger is a t/0.

  • session is 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)
[]

count(ledger)

@spec count(t()) :: non_neg_integer()

How many allocations the ledger holds.

Arguments

  • ledger is a t/0.

Returns

  • the count.

Examples

iex> Agenda.Ledger.count(Agenda.Ledger.new())
0

diff(ledger, session, arrangement)

@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

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]

expire(ledger, now)

@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

  • ledger is a t/0.

  • now is the current moment — a Tempo value or an ISO 8601 string.

Returns

  • {:ok, ledger} with lapsed holds removed; or

  • {:error, reason} when now cannot 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

for_session(ledger, session)

@spec for_session(t(), String.t()) :: [Agenda.Allocation.t()]

What session currently holds.

Arguments

  • ledger is a t/0.

  • session is the session's name.

Returns

  • the session's allocations, or [] when it holds nothing.

Examples

iex> Agenda.Ledger.for_session(Agenda.Ledger.new(), "Review")
[]

hold(ledger, arrangement, options)

@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

Options

  • :until is when the hold lapses — a Tempo value or an ISO 8601 string. Required.

  • :tag is what the claim was for, as a Agenda.Allocation.tag/0. A hold occupies a resource exactly as a booking does, so it carries the same tag. The default is nil.

Returns

  • {:ok, ledger}; or

  • {:error, reason} when :until cannot 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"]

holds(ledger)

@spec holds(t()) :: [Agenda.Allocation.t()]

Every allocation that is still only a hold.

Arguments

  • ledger is a t/0.

Returns

  • the held allocations, ordered as to_list/1 orders them.

Examples

iex> Agenda.Ledger.holds(Agenda.Ledger.new())
[]

new()

@spec new() :: t()

An empty ledger.

Returns

Examples

iex> Agenda.Ledger.count(Agenda.Ledger.new())
0

record(ledger, resource, interval, options \\ [])

@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

  • ledger is a t/0.

  • resource is the Agenda.Resource.t/0 whose time is recorded.

  • interval is when — a Tempo value or an ISO 8601 string.

Options

As claim/4: :tag, :role and :session.

Returns

  • {:ok, ledger}; or

  • {:error, reason} when interval cannot be read as a span. Never for a reason of availability — that is claim/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)
1

And 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"

release(ledger, session)

@spec release(t(), String.t()) :: {:ok, t()}

Free everything a session was holding.

Arguments

  • ledger is a t/0.

  • session is 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

release_series(ledger, series, options \\ [])

@spec release_series(t(), String.t(), keyword()) :: {:ok, t()} | {:error, term()}

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

  • ledger is a t/0.

  • series is the series name — the original session's name, before it was expanded.

Options

  • :from releases 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 :from cannot be read.

Examples

iex> Agenda.Ledger.release_series(Agenda.Ledger.new(), "Stand-up")
{:ok, Agenda.Ledger.new()}

to_list(ledger)

@spec to_list(t()) :: [Agenda.Allocation.t()]

Every allocation in the ledger, whoever holds it.

Arguments

  • ledger is a t/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())
[]