# `DrinkMeAlice.Credits`

The 32-credit RPC admission bound — a fixed slot table whose rows also
hold the bounded request payload.

Design (post-review): the request is *stored in the slot row*, not
carried through the owner mailbox. The owner drains `:enqueued` rows
on a fixed poll — there is no caller→owner message at all, so the
mailbox bound does not depend on caller timing (a delayed doorbell
sender can inject a stale message at an arbitrary later time; a
counter edge cannot bound that — polling can).

- `reserve/2`: one atomic `select_replace` CAS claims a free slot —
  `{n, :free, class, 0, 0, 0, nil}` → `{n, :reserved, class, pid,
  epoch, 0, nil}`. `epoch` is a unique reservation identity; every
  later transition matches on `{n, epoch}` so no release can be
  replayed or land on a recycled slot.
- `enqueue/3`: ONE `select_replace` CAS — `reserved → enqueued` —
  that matches `{n, :reserved, _, self(), epoch, _, nil}` and installs
  the payload via `{:const, payload}` in the same write. Payload
  installation and state publication are atomic: a `reserved` row can
  never hold a payload, so a holder that dies after `reserve` leaves
  a sweepable `reserved`/nil row and nothing more. A stale token
  cannot match — the epoch pin fails on any recycled slot, so no
  write can stomp another slot lifetime.
- `take/3`: owner CAS `enqueued → processing` returning the payload.
  `finish/2`: owner CAS `processing → free`. The slot's credit
  lifetime covers the entire queued-and-processing window — including
  caller death or caller timeout — because freeing is the owner's act
  of consuming the row, not the caller's.
- `release/2`: caller-side free, used only when the reservation was
  provably never enqueued (owner unresolvable before `enqueue`).
- `sweep/1`: frees `:reserved` rows whose holder pid is dead (died
  between reserve and enqueue — no payload exists to orphan).
  `:enqueued`/`:processing` rows are *never* swept — they always carry
  a real payload owned by the live owner; if the owner dies the whole
  table dies with it.

Slot keys are flat integers (1..26 general, 27..32 internal) because
`ets:select_replace` requires a literal non-compound key in the match
head — `{n, …}` keeps every transition a single-key CAS.

Slot bound: exactly 32 reserved requests, total.

Dead-table handling: an `ArgumentError` from ETS is typed as
`:no_table`/empty/zero **only when the table is actually gone**
(`:ets.info` → `:undefined`). On a live table the error is re-raised —
a malformed match spec is a programming defect, not a dead table, and
must crash loudly in tests rather than masquerade as `:no_table`.

# `class`

```elixir
@type class() :: :general | :internal
```

# `payload`

```elixir
@type payload() :: {reference(), map(), integer()}
```

# `token`

```elixir
@type token() :: {non_neg_integer(), non_neg_integer()}
```

# `bounds`

```elixir
@spec bounds() :: map()
```

The declared split for evidence/tests.

# `enqueue`

```elixir
@spec enqueue(:ets.tid(), token(), payload()) :: :ok | {:error, :lost | :no_table}
```

Publish the request payload and flip `reserved → enqueued` in ONE
`select_replace` CAS. `:ok` | `{:error, :lost | :no_table}`.

The match requires `{n, :reserved, _, self(), epoch, _, nil}` — the
payload rides in the spec as `{:const, payload}`, so installation and
publication are a single atomic write. There is no interim
reserved-with-payload state: a holder that dies before the CAS leaves
an ordinary sweepable `reserved`/nil row; a stale token on a recycled
slot fails the epoch match and cannot overwrite anything.

# `enqueued`

```elixir
@spec enqueued(:ets.tid()) :: [
  {non_neg_integer(), non_neg_integer(), integer(), payload()}
]
```

List currently `:enqueued` slots, oldest enqueue first
(`{n, epoch, marked_ms, payload}`). Read-only view for the owner's
eligibility scan; consumption is still the CAS in `take/3`.

# `finish`

```elixir
@spec finish(:ets.tid(), token()) :: :ok
```

Owner-side free once the request is consumed (replied or refused).
Matched on `{n, epoch}` — frees `:processing` (normal) and, defensively,
`:enqueued` rows so a slot listed twice across a poll boundary still
drains to `:free` exactly once.

# `new`

```elixir
@spec new() :: :ets.tid()
```

Create the slot table (all slots `:free`). Owned by the caller.

# `release`

```elixir
@spec release(:ets.tid(), token()) :: :ok
```

Caller-side free of a reservation that was never enqueued — used when
the owner could not be resolved. Epoch- and holder-pinned; at most once.

# `reserve`

```elixir
@spec reserve(:ets.tid(), class()) ::
  {:ok, token()} | {:error, :exhausted | :no_table}
```

Atomically claim a free slot of `class` for the calling pid.
`{:ok, {n, epoch}}` | `{:error, :exhausted | :no_table}`.

# `snapshot`

```elixir
@spec snapshot(:ets.tid()) :: map()
```

Current per-class usage and queue-state counts — for health/evidence.

# `sweep`

```elixir
@spec sweep(:ets.tid()) :: non_neg_integer()
```

Reclaim `:reserved` slots whose holder died between reserve and
enqueue — the only state without an installed payload. `:enqueued` and
`:processing` rows are left to the owner, which always consumes them.
Returns the number reclaimed.

# `take`

```elixir
@spec take(:ets.tid(), non_neg_integer(), non_neg_integer()) ::
  {:ok, payload()} | :none
```

Atomically claim one enqueued slot for processing —
`enqueued → processing` in one CAS, returning the stored payload.
`{:ok, payload}` | `:none`.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
