Arkveil

Runtime

How Arkveil runs in production — a self-hosted sidecar that mirrors the access model, evaluates every decision locally, and keeps answering even if Arkveil Cloud becomes unreachable.

arkveil-runtime is how Arkveil runs in production — a self-hosted service that keeps authorization inside your infrastructure. Deciding against Arkveil Cloud directly is the path for first experiments, not for production traffic. The runtime runs as a container: it polls Arkveil Cloud for model changes and maintains a complete local copy of your workspace's access model — the mirror — in a PostgreSQL database you provide. Every decision is evaluated locally against the mirror, without a round trip to Arkveil Cloud: the runtime answers from the last synced state, within a staleness policy you control.

The runtime serves the same decision API as Arkveil Cloud — permission checks, read conditions, write checks, and touch conditions — under the same contract. The SDK treats the base URL as opaque configuration: point serviceUrl at your runtime instead of Arkveil Cloud and nothing else changes.

Two things only the runtime can do:

  • Decide through outages. Arkveil Cloud being unreachable stops syncing, not deciding.
  • Evaluate dataset references. Action policies that look up your data at decision time — exists invoice where … — need read access to your databases. Arkveil Cloud never connects to them, so asked of Cloud alone such a condition does not grant. The runtime evaluates it against the datasources you register with it.

Prerequisites

  • Docker, or any OCI-compatible runtime.
  • A reachable PostgreSQL 16 or newer database for the mirror — your own or a managed one, Supabase included. The runtime creates and owns a dedicated arkveil_runtime schema in it and migrates it automatically at startup, so a database shared with other applications is fine — existing schemas are never touched. The connecting user needs the CREATE privilege on the database.
  • Connect to that database directly or through a session-mode pooler — never through a transaction-mode pooler (PgBouncer in transaction mode, Supabase's pooler on port 6543, Neon -pooler hosts). Transaction pooling breaks prepared statements and the advisory locks that serialize migration and sync, and the runtime refuses known transaction-pooler endpoints at startup. On Supabase, from an IPv4-only network use the session pooler on port 5432.
  • Your workspace API key, created with arkveil keys create — the runtime presents it when polling for model changes.

Pull

The image is public — no registry login is required:

docker pull ghcr.io/arkveil-abac/arkveil-runtime:latest

Two kinds of tags are published: latest tracks the current build, and sha-<commit> tags are immutable builds. Pin a sha-* tag in production and upgrade deliberately. Images are multi-arch (linux/amd64, linux/arm64).

Configuration

Everything is configured through environment variables:

VariableRequiredDefaultMeaning
PGURLyes*—Complete mirror connection string — credentials may be left out and supplied via PGUSER / PGPASSWORD. Example: postgres://user:password@host:5432/db?sslmode=require
PGHOST / PGPORT / PGDATABASEyes*—Mirror host, port, and database name — the alternative to PGURL
PGUSER / PGPASSWORDyes*—Mirror credentials
ARKVEIL_KERNEL_URLyes—Arkveil Cloud base URL: https://api.arkveil.com
ARKVEIL_KERNEL_API_KEYyes—Workspace API key used for polling model changes. Startup fails if blank
ARKVEIL_RUNTIME_INBOUND_API_KEYSyes, to serveemptyComma-separated keys your applications send as X-Api-Key. Empty means deny-all
ARKVEIL_RUNTIME_POLL_INTERVALno30sHow often to poll for model changes
ARKVEIL_RUNTIME_SYNC_FAIL_MODEnoSERVE_STALEBehavior past the staleness bound — see Staleness
ARKVEIL_RUNTIME_SYNC_STALENESSBOUNDno10× poll intervalHow stale the mirror may get before the runtime reacts, e.g. 5m
SERVER_PORTno8087HTTP port
JAVA_OPTSnoemptyExtra JVM flags. Under a container memory limit, -XX:MaxRAMPercentage=75 is recommended
SPRING_CONFIG_ADDITIONAL_LOCATIONno—Location of the mounted datasource file — next section

* Configure the mirror either as one PGURL or as the individual PG* variables. Startup fails if neither is set, if both are mixed for the same value, or if the endpoint is a known transaction-mode pooler.

The mirror on Supabase

A Supabase project works as the mirror database. The only trap is the pooler choice.

In the project dashboard press Connect and pick the connection string:

OptionHost and portUse when
Direct connectiondb.<project-ref>.supabase.co:5432your network has IPv6 egress, or the project has the IPv4 add-on
Session pooleraws-0-<region>.pooler.supabase.com:5432, username postgres.<project-ref>IPv4-only networks — most container platforms
Transaction poolerport 6543never for the runtime — refused at startup

The session pooler and the transaction pooler share a hostname. The port is what distinguishes them: 5432 works, 6543 is refused.

Pass the string as one PGURL, with the password as a separate secret. The remaining variables are unchanged:

PGURL='postgres://postgres.<project-ref>@aws-0-<region>.pooler.supabase.com:5432/postgres'
PGPASSWORD='<from your secret manager>'

Embedding the password in the URL also works — percent-encode any special characters in it. The postgres://, postgresql:// and jdbc:postgresql:// schemes are all accepted, so whichever string Supabase hands out pastes directly. Keep each value in exactly one place: PGURL must not be mixed with PGHOST, PGPORT or PGDATABASE, and a credential must not appear both inside the URL and as PGUSER or PGPASSWORD. The runtime fails fast on any mix, and the log names the variable to fix.

The mirror lives in its own arkveil_runtime schema, so a project whose public schema is already in use is fine. Supabase's Data API exposes public only, so the mirror is never reachable through it. The postgres user has the rights the runtime needs. Verification is the same as the general flow below.

Connecting your databases

Action policies with dataset references — exists invoice where … in a permission condition — need the runtime to query the tables they name. Register each datasource in a mounted YAML file, keyed by the datasource name defined in Studio — the names must match, and there is no implicit default:

# /config/arkveil-datasources.yml
arkveil:
  runtime:
    datasources:
      demo_billing:
        jdbc-url: jdbc:postgresql://billing-db:5432/app?connectTimeout=15&socketTimeout=60&tcpKeepAlive=true
        username: arkveil_readonly
        password: ${BILLING_DB_PASSWORD}   # resolved from the container environment
SPRING_CONFIG_ADDITIONAL_LOCATION=file:/config/arkveil-datasources.yml

The database user must be read-only. The runtime additionally opens its connections read-only, but the read-only user is the authoritative guard, not the fallback. Set driver-level connectTimeout and socketTimeout in each URL so a severed connection fails fast — for PostgreSQL the units are seconds.

A datasource that is missing, down, or misconfigured degrades only the requests that need it. The decision is denied with a named reason instead of an error, and the runtime self-heals without a restart:

{"granted": false, "reason": "DATASOURCE_UNRESOLVED", "mode": "NORMAL"}

The runtime's own mirror database is never used for dataset lookups.

Quickstart

The fastest way to try the runtime — one compose file, mirror database included:

services:
  mirror-db:
    image: postgres:16
    environment:
      POSTGRES_DB: arkveil_runtime
      POSTGRES_USER: arkveil
      POSTGRES_PASSWORD: change-me
    volumes:
      - mirror-data:/var/lib/postgresql/data

  arkveil-runtime:
    image: ghcr.io/arkveil-abac/arkveil-runtime:latest
    depends_on:
      - mirror-db
    ports:
      - "8087:8087"
    environment:
      PGHOST: mirror-db
      PGPORT: "5432"
      PGDATABASE: arkveil_runtime
      PGUSER: arkveil
      PGPASSWORD: change-me
      ARKVEIL_KERNEL_URL: https://api.arkveil.com
      ARKVEIL_KERNEL_API_KEY: ${ARKVEIL_KERNEL_API_KEY}
      ARKVEIL_RUNTIME_INBOUND_API_KEYS: ${ARKVEIL_RUNTIME_INBOUND_API_KEYS}
      JAVA_OPTS: -XX:MaxRAMPercentage=75
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:8087/actuator/health/readiness"]
      interval: 10s
      timeout: 3s
      retries: 30

volumes:
  mirror-data:

No startup ordering is needed — the runtime retries the mirror connection long enough for a fresh database to come up.

In production keep the same container, but point it at a durable mirror database instead of a sibling container, pin a sha-* image tag, and supply the secrets from your secret manager. Upgrades and Security notes below cover the details.

Verify

Liveness is up as soon as the process is:

curl http://localhost:8087/actuator/health/liveness
{"status":"UP"}

The mirror endpoint shows sync progress. After the first successful sync:

curl http://localhost:8087/actuator/mirror
{
  "loaded": true,
  "epoch": "…",
  "workspaceId": "…",
  "revision": 936,
  "lastSyncedAt": "2026-09-07T01:10:23Z",
  "staleness": "PT2.6S",
  "stalenessBound": "PT5M",
  "pollInterval": "PT30S",
  "withinBound": true
}

Readiness flips up once the mirror has synced and is within the staleness bound — point orchestrator readiness probes at /actuator/health/readiness, and liveness probes at /actuator/health/liveness, never at the bare /actuator/health.

Now prove the whole path with a real decision. Every request carries one of the configured inbound keys as X-Api-Key:

curl -X POST http://localhost:8087/api/v1/abac/permissions/check \
  -H 'X-Api-Key: <inbound-key>' \
  -H 'Content-Type: application/json' \
  -d '{"actionCode": "invoices:edit", "user": {"id": "u-42", "role": "manager", "region": "EU"}, "context": {}}'
{"granted": true, "mode": "NORMAL"}

Point the SDK at it

The inbound key is exactly what the SDK sends as its apiKey, so switching from Arkveil Cloud to your runtime is one line:

const arkveil = new Arkveil({
  serviceUrl: "http://arkveil-runtime:8087",
  apiKey: process.env.ARKVEIL_INBOUND_KEY!,
});

Permission checks, buildReadCondition, buildWriteChecks, and buildTouchCondition all work unchanged — same contract, local evaluation.

Staleness and failure semantics

  • Arkveil Cloud unreachable — the runtime keeps serving from the mirror. Readiness stays up while staleness is within the bound.
  • Staleness exceeds the bound — readiness flips down, and behavior follows ARKVEIL_RUNTIME_SYNC_FAIL_MODE: SERVE_STALE (the default) keeps answering from the last synced state, FAIL_CLOSED denies until the mirror catches up.
  • Every response carries a mode field, so staleness is visible per call: NORMAL means the mirror is fresh, MIRROR_STALE means the decision was served past the bound, MIRROR_EMPTY means no sync has succeeded yet. Callers can apply their own per-call policy to a stale grant.
  • A denied check may carry a reason naming a degradation — DATASOURCE_UNRESOLVED for a datasource with no registered entry, DATASOURCE_ERROR for one that failed. Read conditions, touch conditions and write checks may carry one too, even when they still admit rows. ATTRIBUTE_INCOMPATIBLE names an attribute value that does not match its declared type. Such a value counts as absent. A response with no reason is an ordinary policy outcome.
  • When Arkveil Cloud comes back, the next poll catches the mirror up automatically.

Topologies

An instance is one running arkveil-runtime container. Its mirror is the arkveil_runtime schema it syncs from Arkveil Cloud and decides from. The topology question is how many instances read one mirror.

  • One mirror shared by all replicas — the recommended production shape. Point every replica of a workspace's runtime at the same mirror database. All replicas decide from the same synced state, so their decisions agree. The fleet polls Arkveil Cloud about once per interval instead of once per replica. Migration and sync coordinate automatically through advisory locks on that database, with nothing to configure. The mirror database is a dependency every replica shares.
  • One mirror per instance — for strict sidecar deployments, where every node must keep deciding on its own whatever happens to the others. Each instance syncs its own mirror. Two instances can disagree for up to one poll interval while a change propagates. Each instance also needs its own database or schema.

Upgrades

Pull the new tag and restart — the mirror schema migrates forward automatically, and with a shared mirror a rolling restart is fine. Downgrades are not supported: to roll back, deploy the previous tag against a mirror snapshot, or let a fresh mirror re-sync from scratch.

Keep runtimes current with the platform. Policies may use capabilities your runtime build predates, and an outdated runtime fails closed on a model it does not understand — decisions are refused, never mis-evaluated. Upgrade your runtimes before adopting newly released policy features.

Security notes

  • Treat ARKVEIL_KERNEL_API_KEY, the inbound keys, and all database passwords as secrets — a secret manager, not image layers.
  • Datasource users must be read-only.
  • The container runs as a non-root user.
  • Expose the port only inside your trust boundary. The decision API is for your backends, not for browsers or end users.

Next steps

  • Data policies — the SQL the runtime renders and your application enforces.
  • SDK — the client that talks to it.
  • Access model — dataset references, the rules that need a runtime.

On this page