Runtime safety and admission control

View Source

Version 0.3.0 uses Erlang process ownership and monitoring for runtime safety. The controls in this document are local runtime controls: they do not replace endpoint authentication, authorization at a network boundary, or the existing human-confirmation suspension flow.

Admission control

adk_admission_control limits active work globally and per agent. Capacity is represented by an opaque permit owned by an Erlang process. The controller monitors that owner and returns capacity if the process exits, so a crashed request process cannot leak a slot.

The controller supports two overflow policies:

  • reject returns {error, concurrency_limit_reached} immediately.
  • queue enters a bounded queue and waits until capacity is available or an absolute monotonic deadline expires.

Queue order is FIFO per agent. Across agents, the controller selects the oldest currently eligible request. This prevents an agent already at its own limit from blocking unrelated agents while retaining deterministic order for each agent.

Example controller configuration:

#{global_limit => 256,
  default_agent_limit => 16,
  agent_limits => #{<<"BatchAgent">> => 4,
                    <<"InteractiveAgent">> => 32},
  overflow => queue,
  max_queue => 1024,
  default_queue_timeout => 30000}

For direct use, always release in an after block. acquire/3 associates the permit with the calling process unless an explicit owner pid is supplied.

Work = fun() -> <<"admitted work result">> end,
Deadline = erlang:monotonic_time(millisecond) + 5000,
case adk_admission_control:acquire(
       adk_admission_control, <<"InteractiveAgent">>,
       #{overflow => queue, deadline => Deadline}) of
    {ok, Permit} ->
        try
            Work()
        after
            ok = adk_admission_control:release(
                   adk_admission_control, Permit)
        end;
    {error, concurrency_limit_reached} ->
        retry_later;
    {error, queue_deadline_exceeded} ->
        request_timed_out
end.

submit/3 is the non-blocking form. It returns a request reference and either an immediate permit or a queued deadline. A queued caller can use await/2 or cancel the request with cancel/2. Cancellation also accepts the request reference of active work and returns its permit exactly once.

The controller monitors both the queued caller and an explicit queued owner. Caller death removes an abandoned reply target. Owner death removes the work and reports {error, owner_down} to a surviving caller. Active owner death, deadline expiry, cancellation, and explicit release all converge on one permit revocation path.

status/0 returns aggregate counts and configured limits. It deliberately contains no pids, monitor references, permit references, queue request references, arguments, or content.

Runtime authorization policy

adk_runtime_policy compiles an immutable allow/deny and byte-budget policy. The policy is fail-closed:

  • an omitted agent or tool allow selector is an empty list;
  • deny takes precedence over allow;
  • malformed policies, subjects, arguments, or content are denied;
  • argument and content limits are finite non-negative integers;
  • the defaults are 64 KiB of canonical JSON tool arguments and 1 MiB of content.

Use allow => all only when the runtime boundary is intentionally permissive. The policy is also opt-in: it is enforced only when trusted Runner or Agent Config composition options install it. Merely starting Erlang ADK does not create organization roles, map users to permissions, or authorize connector permission labels. Applications still own identity, role, and backend authorization, and must deliberately pass this policy at the runtime boundary.

{ok, Policy} = adk_runtime_policy:compile(
  #{id => <<"production-v1">>,
    agents => #{allow => [<<"Writer">>, <<"Reviewer">>],
                deny => [<<"LegacyAgent">>]},
    tools => #{allow => [<<"weather">>, <<"search_docs">>],
               deny => [<<"shell">>]},
    max_argument_bytes => 32768,
    max_content_bytes => 262144}),

{allow, AgentAudit} = adk_runtime_policy:check_agent(
                        Policy, <<"Writer">>, UserContent),
{allow, ToolAudit} = adk_runtime_policy:check_tool(
                       Policy, <<"weather">>, ToolArguments).

Binary content is measured as its UTF-8 payload size. Structured content and tool arguments are normalized with adk_json and measured as canonical JSON. Unsupported Erlang runtime values such as pids, references, ports, and funs are denied rather than stringified.

Every check returns an audit decision with:

  • a decision ID and timestamp;
  • policy ID and policy fingerprint;
  • operation, subject, outcome, and bounded reason tag;
  • measured bytes and the applicable limit;
  • a digest of the immutable decision value.

Audit decisions never contain runtime arguments, content, credentials, exception text, pids, references, ports, or functions. Callers can append the decision to their durable event/audit store. The digest is a corruption check, not a cryptographic signature; deployments requiring independently verifiable audit logs should sign or hash-chain stored events at the storage boundary.

Runner and tool ordering contract

The Runner integration observes these ordering rules:

  1. Acquire the per-agent/global admission permit for an invocation.
  2. Check the agent allow/deny rule and original input-content budget before run lifecycle plugins or model work.
  3. Run on_user_message/before_run, then recheck the effective input-content budget before persisting it.
  4. Resolve dynamic toolsets using normal Erlang ADK precedence.
  5. Check the final resolved tool name and arguments against runtime policy.
  6. Run global then local tool callbacks for allowed calls only.
  7. If the tool requires human confirmation, suspend through the existing confirmation mechanism; a policy denial never becomes an approval request.
  8. Execute the tool and check bounded tool-result and final-output content.
  9. Release the invocation permit in after, including failures and cancellation.

Policy enforcement therefore does not replace dynamic toolsets, alter plugin precedence, or serialize independent Erlang processes. Admission is centralized only for counters and queue ownership; admitted agent and tool work remains concurrent.

In-development 0.10 service supervision

The optional 0.10 services preserve explicit ownership and bounded failure semantics:

  • adk_runtime_service_bundle treats its artifact and memory routers as one generation. A router exit stops the bundle so supervision creates fresh, consistent references; it never swaps a child behind a reference held by a Runner. The ephemeral profile uses one shared ETS adapter per component and globally enforces that adapter's quotas. The durable profile uses exact-scope workers with owner-bound, exactly-once per-operation leases and carries one absolute deadline through cold-route admission, resolution, and handoff. Caller exit, timeout, and dropped-handoff cleanup cannot pin capacity or install a stale shard. Reclamation is LRU-on-capacity only after the configured idle timeout; durable data survives worker reclamation. If every worker is active or too recent, max_active_scopes rejects the new cold scope. The durable bundle also privately owns the Mnesia memory outbox: deterministic registry hydration and exact-identity filters gate a bounded rotating claim cursor; ordered indexes cover due/lease/epoch/terminal work; constant-row four-table health and majority readiness fail closed; and epoch-bound IDs plus a hard active-and-terminal reservation preserve erasure and capacity semantics. Legacy named APIs route to that one owner. Nested configuration/capabilities are strict, and status/crash output is redacted.
  • adk_eval_service bounds active and queued jobs, stores lifecycle changes atomically, allows only one service owner per backend-canonical identity shared across wrapper modules, and acquires owned durable storage before initialization/reconciliation. Raw submission preparation runs in at most 64 monitored one-second, heap-bounded workers instead of blocking the service mailbox. Recovery fails stored queued/running jobs instead of replaying them, and the service stops if it cannot persist a terminal task outcome.
  • adk_trace_store bounds global and per-principal retained metadata by event count, bytes, and time. Workflow delivery through its opaque receiver is a non-blocking best-effort cast with a hard input-size limit and atomic pending-admission ceiling; forged capabilities, oversized events, retention-capacity pressure, back-pressure, or an unavailable store cannot make successful workflow work fail. Ordered indexes bound cursor paging and each expiry-prune batch. The store monitors every local workflow owner bound to a capability and renews an expired receiver while any owner remains live, then allows normal TTL pruning after their DOWN messages. Application enablement wires the standard configured Runner and public start/run workflow-facade paths; direct constructors remain explicit.
  • adk_invocation_ledger_mnesia:init/1 validates an existing table before any durable invocation uses it. Record name/attributes, set type, majority setting, and local disc_copies durability must match; incompatible or volatile tables fail startup instead of being reinterpreted or downgraded. Migration and table selection remain operator actions.

The three supervised integrations above are disabled by default in application environment. The durable runtime/evaluation adapters still require deployment-owned backup, restore, filesystem/Mnesia, and multi-node policy. Trace retention is volatile and never substitutes for a durable audit store. These APIs remain IN DEVELOPMENT; see VERSION_0_10_0.md.

Telemetry

Admission decisions emit [erlang_adk, admission, decision] with wait time, active/queued counts, outcome, and agent ID.

Runtime policy decisions emit [erlang_adk, policy, decision] with measured bytes, limit, policy ID, operation, outcome, and reason. Neither event includes arguments, content, credentials, exception terms, pids, references, request references, or permit references.

Phoenix ownership

Phoenix should normally start an adk_run and subscribe by stable run ID. The Runner invocation worker owns the admission permit automatically; neither the LiveView nor the short-lived HTTP request process should own or link the run. If an application uses adk_admission_control directly for non-Runner work, set owner to the supervised worker that actually performs that work. Process monitoring then supplies cleanup without polling or a separate lease service.