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_denyresolution (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 | :liveis 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.
Summary
Types
The opaque transport container: a %Plug.Conn{} (:plug) or a socket / %{assigns: ...} (:live).
Bounded, no-PII deny context (D-12).
The tiered, host-resolvable deny form (D-11).
Functions
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}.
Returns the configured deny path (config :accrue, :entitlements, deny_path:,
default "/") for the LiveView surface's deny_live/3 to redirect to.
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.
Resolves the billable for container, total and never raising. Precedence
Types
@type container() :: term()
The opaque transport container: a %Plug.Conn{} (:plug) or a socket / %{assigns: ...} (:live).
@type ctx() :: %{ guard: :feature | :plan, required: atom() | String.t(), reason: atom(), billable: term() | nil, surface: :plug | :live }
Bounded, no-PII deny context (D-12).
@type deny_form() :: :forbidden | {:redirect, String.t()} | {non_neg_integer(), iodata()} | (container(), map() -> term()) | {module(), atom(), list()}
The tiered, host-resolvable deny form (D-11).
Functions
@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-arityfn container -> billable | niloverride (wins over the config global and the default probe).:on_deny— a per-guarddeny_form/0override (wins over the config global and the built-in:forbidden).:status— a per-guard plug status override (default403).
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).
@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.
@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 JSONaccept, else"Forbidden"); honors astatus:opt override.{:redirect, path}→302with alocationheader (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]).
Resolves the billable for container, total and never raising. Precedence:
- per-guard
billable:opt (1-arity fn), config :accrue, :entitlements, billable:(1-arity fn ornil),- 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).