# `Accrue.Entitlements.Guard`
[🔗](https://github.com/szTheory/accrue/blob/accrue-v1.5.1/lib/accrue/entitlements/guard.ex#L1)

The always-compiled, LiveView-runtime-free decision engine shared by both
enforcement surfaces (`Accrue.Plug.RequireEntitlement` and the cond-compiled
`Accrue.Live.Entitlements` `on_mount` guard).

This module owns ALL guard decision logic so the two surface modules stay
thin transport adapters:

  * **Billable resolution** (D-14/D-15) — one shared 1-arity convention.
  * **Resolve-once** (D-17) — the billable is resolved exactly once per
    request/mount and stashed billable-only (never the boolean decision).
  * **Fail-closed delegation** (D-08) — the allow/deny decision is delegated
    to the Phase 123 gate (`Accrue.Entitlements.entitled?/3` /
    `has_active_plan?/3`); the Guard NEVER makes its own allow decision and
    NEVER re-queries subscriptions or reads `.status`.
  * **Tiered `on_deny` resolution** (D-11) — per-guard opt → config global →
    built-in `:forbidden`.
  * **Bounded, no-PII `ctx`** (D-12) — `%{guard:, required:, reason:,
    billable:, surface:}`.
  * **`surface:` telemetry dimension** (D-18) — `:plug | :live` is carried
    onto the inherited `[:accrue, :entitlements, :check]` span.

It deliberately holds NO LiveView/socket runtime reference (none of the
LiveView, Component, Socket, or Controller Phoenix modules) so it passes the
Plan 06 "core stays runtime-LiveView-free" merge gate — it lives OUTSIDE
`lib/accrue/live/` and reads only an opaque `container` term (a `%Plug.Conn{}`
for `:plug`, a socket / `%{assigns: ...}` map for `:live`).

## Security

The billable is resolved from **server-side assigns only** — `container.assigns`
via the host `billable` fn or the default `current_scope.user`/`current_user`
probe — NEVER from request params or headers. The `accept` header is read for
content negotiation only, never for authorization. The deny path is the
fail-closed default: a `nil` billable, a raising `billable` fn, or any
resolution exception all collapse to a deny.

## Deny reason

The host-facing `ctx.reason` is **coarse by design** (WARNING 2 / D-12). It
defaults to `:not_entitled` and is best-effort `:no_active_subscription` /
`:error` only where cheaply known, because the boolean predicate the Guard
delegates to returns NO reason and we deliberately do NOT make a second gate
call to fetch one (D-08/D-17 — exactly one gate call per check). The PRECISE
Phase 123 reason atom (`:not_entitled | :no_active_subscription |
:unmapped_plan | :error`) lives in the inherited `[:accrue, :entitlements,
:check]` **telemetry span** metadata, NOT in `ctx`. This converts the silent
narrowing into a documented contract: read the span for the precise reason,
read `ctx.reason` for a coarse host-facing bucket.

# `container`

```elixir
@type container() :: term()
```

The opaque transport container: a `%Plug.Conn{}` (`:plug`) or a socket / `%{assigns: ...}` (`:live`).

# `ctx`

```elixir
@type ctx() :: %{
  guard: :feature | :plan,
  required: atom() | String.t(),
  reason: atom(),
  billable: term() | nil,
  surface: :plug | :live
}
```

Bounded, no-PII deny context (D-12).

# `deny_form`

```elixir
@type deny_form() ::
  :forbidden
  | {:redirect, String.t()}
  | {non_neg_integer(), iodata()}
  | (container(), map() -&gt; term())
  | {module(), atom(), list()}
```

The tiered, host-resolvable deny form (D-11).

# `check`

```elixir
@spec check(:plug | :live, container(), keyword()) ::
  {:allow, container()} | {:deny, deny_form(), ctx()}
```

Resolves the billable (precedence + resolve-once + fail-closed), delegates the
allow/deny decision to the Phase 123 gate carrying `surface:`, and returns
either `{:allow, container}` or `{:deny, deny_form, ctx}`.

`opts` keys:

  * `:feature` | `:plan` — exactly one; selects the gate predicate.
  * `:billable` — a 1-arity `fn container -> billable | nil` override (wins
    over the config global and the default probe).
  * `:on_deny` — a per-guard `t:deny_form/0` override (wins over the config
    global and the built-in `:forbidden`).
  * `:status` — a per-guard plug status override (default `403`).

For the `:plug` surface the resolved billable is stashed once on the conn via
`Plug.Conn.assign(conn, :accrue_billable, billable)` (read first; resolved at
most once). For the `:live` surface the resolved billable is stashed onto the
returned container's assigns via a plain map update (NO `Component.assign`
reference here) so the cond-compiled surface can mirror it with `assign_new`
(read first; resolved at most once).

# `deny_path`

```elixir
@spec deny_path() :: String.t()
```

Returns the configured deny path (`config :accrue, :entitlements, deny_path:`,
default `"/"`) for the LiveView surface's `deny_live/3` to redirect to.

# `deny_plug`

```elixir
@spec deny_plug(Plug.Conn.t(), deny_form(), ctx(), keyword()) :: Plug.Conn.t()
```

Translates a resolved `deny_form` on the Plug surface and halts the conn.
The wire body is OPAQUE (D-10) — `ctx.required`/feature/plan is NEVER echoed
into the response body; it reaches the host only via `ctx` passed to host
`on_deny` fns.

  * `:forbidden` → content-negotiated opaque 403 (`{"error":"forbidden"}`
    for JSON `accept`, else `"Forbidden"`); honors a `status:` opt override.
  * `{:redirect, path}` → `302` with a `location` header (pure Plug).
  * `{status, body}` → `send_resp(status, body)`.
  * a 2-arity fn → `fun.(conn, ctx)`.
  * an MFA `{m, f, a}` → `apply(m, f, a ++ [conn, ctx])`.

# `resolve_billable`

```elixir
@spec resolve_billable(:plug | :live, container(), keyword()) :: term() | nil
```

Resolves the billable for `container`, total and never raising. Precedence:

  1. per-guard `billable:` opt (1-arity fn),
  2. `config :accrue, :entitlements, billable:` (1-arity fn or `nil`),
  3. the default probe — `container.assigns.current_scope.user` →
     `container.assigns.current_user` → `nil`.

The host `billable` fn call is wrapped in the same `rescue`/`catch → nil` as
`Accrue.Entitlements.resolve/1`, so a raising host fn fails closed (`nil`
flows to the gate, which denies).

---

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