---
title: 'ADR 0002: Gateway architecture'
description: Two separate planes, built from one image, with the credential broker placed after everything that can refuse.
label: '0002 Gateway architecture'
---

## Context

Agents and people need very different things from Veefore.

People manage: create agents, author policies, issue delegations, register
credentials, read audit. Low volume, bursty, highly privileged, and the surface
is large.

Agents ask: may I do this. High volume, latency sensitive, and the only thing
they ever need is a decision.

Serving both from one process means the surface agents can reach includes
`POST /v1/policies/{id}/activate`.

## Decision

Two request-serving processes.

| | Serves | Reached by |
| --- | --- | --- |
| Control plane | `/v1/*` | People, console, CLI, CI |
| Gateway | `/gateway/v1/*` and `/.well-known/veefore-platform-key` | **Agents only** |

Plus a worker for queues and rollups. All three are built from **one container
image** with three entry points, because they share almost all their code.

The gateway accepts no human credentials. It authenticates a signed Ed25519
assertion and nothing else.

Both planes are **stateless**: session state and the job queue live in
PostgreSQL, so horizontal scaling needs no coordination.

### The pipeline order

The twelve stages run in a fixed order, and one position in it is a security
property rather than a performance choice:

```text
 1–9   everything that can refuse
10     CREDENTIAL   ← first side effect outside Veefore
11     EXECUTION
12     RESULT
```

## Alternatives

**One process serving both.** Simpler to deploy, and it means the surface agents
can reach includes policy activation and credential registration. Every
mitigation for that is a configuration — a reverse proxy rule, a path filter —
and configurations drift.

**A sidecar per agent.** Lower latency, and the enforcement point becomes
something running in the agent's own trust domain. An agent that can stop its
own sidecar can stop being enforced. Also multiplies the number of places a
credential is unsealed.

**Three separate codebases.** Clean separation, and it guarantees the planes
drift: a policy change applied in one and not the other, three things to scan
and sign, three dependency trees. The shared packages are the bulk of the code
anyway.

**Broker the credential earlier, in parallel with policy evaluation.** Would
save a few milliseconds on the allowed path. It would also mean a blocked action
had already caused a credential to be issued — so "we blocked it" and "the
secret did not go out" would become two different claims with only one of them
true. Rejected on that basis alone.

## Consequences

**Separate ingresses are mandatory, not advisory.** Exposing `/v1/*` on the
ingress agents can reach undoes the entire decision. This has to be stated in
the deployment documentation and checked in review, because the manifests cannot
enforce it for you.

**The planes scale independently**, which is the operational benefit. Typical
ratio is 3:1 gateway to control plane, because their load profiles are nothing
alike.

**Each process gets only the secrets it needs.** The gateway never issues a
human session, so it does not need `VEEFORE_SESSION_SECRET`. A secret a process
does not need is one more place a compromise can read it from.

**Policy evaluation must be in-process.** The gateway cannot take a network hop
to another service in order to decide whether to allow something — that service
could be down, and fail-closed would then turn every evaluation-service blip into
a refusal of all traffic. This constraint drives
[ADR 0003](/architecture/adr/0003-policy-engine).

**Latency is bounded by the stage order, not optimised against it.** The full
pipeline excluding execution runs in 20–50 ms. Could be faster by caching a
decision; caching a decision is what makes revocation take effect "eventually"
rather than immediately.

**Two readiness surfaces to monitor.** Both planes expose `/health/ready` and
both genuinely check the database. A plane that is live and not ready must leave
the load balancer's rotation rather than serving errors — which once silently
failed, because the embedded driver booted a fresh empty database after close
and left readiness green with nothing behind it.

## Related

- [The boundary](/secure/boundary)
- [Request path](/architecture/request-path)
- [Deployment](/operate/deployment)
- [Deployment model](/architecture/adr/0013-deployment-model)
