serve
Audience: operators running Aperture as a long-lived service.
aperture serve [options]
serve hand-wires the full dependency graph (storage → engine → service → HTTP handler) and boots a net/http server exposing the HTTP + Twirp API and
the admin UI. It shuts down gracefully on SIGINT / SIGTERM, draining
in-flight requests within a 10-second window. This is the same fully-wired facade
the mutation CLI commands build — the server just puts it behind a listener and
an authenticator.
bin/aperture serve --addr :8080
aperture serving on :8080
Press Ctrl-C to trigger a graceful shutdown (shutting down...).
What the flags control
-
--addr— the TCP address to listen on (default:8080). -
--seed/--store— the model to serve, exactly as elsewhere (see Global options). With no--store, the server runs against an in-memory model seeded from--seedor, when that is omitted too, the embedded example — the zero-flag demo. With a--storeDSN and no--seed, nothing is seeded: the server reads the model already in that database and writes no model rows on startup. See Booting against a database below. -
--auth— the authenticator adapter that maps each request to a principal:dev(the default — the bearer token is the principal id, no external IdP),oidc, orparsec. It overrides theAPERTURE_AUTH_MODEenv var. Because the default isdev,serveruns with no external identity provider out of the box;oidcandparsecare opt-in. -
--enforce-membership— defence-in-depth: deny any decision whose principal is not a member of the active account before grants are consulted. This lets a single shared role (manager, analyst, …) be reused across accounts without one account's grants leaking to another's members. Also settable viaAPERTURE_ENFORCE_MEMBERSHIP. -
--wiring-poll— re-read the shared wiring tables on an interval instead of only at startup, so aaperture wiring pushfrom another host is picked up without a restart. Omitted means off, and off means off: no background reader is started and no periodic query is made. Also settable viaAPERTURE_WIRING_POLL; the flag wins when both are given. See Noticing a push without a restart below. -
--enumerate-limit— the ceiling oneEnumerateis bounded by: the number a request with a non-positivelimitreceives, and the number a larger requestlimitis clamped down to. It also bounds the scope member gather, so the gather and the result cap are one value. Unset leaves the engine on its documented default of1000. Also settable viaAPERTURE_ENUMERATE_LIMIT; the flag wins when both are given.bin/aperture serve --enumerate-limit 1500 APERTURE_ENUMERATE_LIMIT=1500 bin/aperture serveIt is not a
serveflag. It describes the deployment, not the server, so the same flag and the same variable are carried bycheck,enumerate,identifiers,explainandmcp, and all of them resolve it identically — one binary cannot be configured to answer 1500 over HTTP and 1000 on the command line. The wiring lives in the decision stack every command builds, not inserve's own options (which hold only--enforce-membership, a posture that really is the server's alone).APERTURE_ENUMERATE_LIMIT=1500 bin/aperture enumerate alice list 'account:acme/**'A value that is not a whole number greater than zero fails the command with
APERTURE_CONFIG_INVALIDnaming the setting and the value it rejected — underserve, before the store is opened — rather than quietly serving the default. That coversbanana, and it covers0and-5too: the engine'sWithEnumerateLimitnormalises a non-positive bound to the default, which is the right answer for a Go embedder passing a computed number and the wrong one for a human who typed one. An operator who wrote-5would be served1000while believing otherwise, so the CLI refuses at the boundary what the library would have absorbed. To get the default, omit the setting.
Booting against a database
serve with a --store DSN and no --seed seeds nothing. It runs Setup
(which creates missing tables and never migrates), reads the model that is
already there, and writes no model rows of its own.
# A second instance, against a database another process provisioned:
bin/aperture serve --store 'postgres://aperture@db/aperture'
This is what makes a long-lived deployment safe and what lets two instances
share one database. Passing a --seed alongside a durable --store still
applies that document in full, on every boot — which is how you provision a
database on purpose, and which two instances pointed at the same database must
not both do, or each restart re-asserts one instance's model over the other's.
# Provisioning, deliberately and once:
bin/aperture serve --store 'postgres://aperture@db/aperture' --seed ./model.yaml
Where its wiring comes from
Seeding nothing is not the same as being wired by nothing. After Setup, serve
reads the shared wiring tables and builds its object
providers, field types and attribute slots from them — so an instance with a
--store DSN and no seed file on disk at all is a fully wired instance.
When those tables hold no rows — every deployment that has never run
aperture wiring push — the local seed file's wiring is used exactly as it always
was. There is no flag and nothing to configure; see
What an omitted --seed means
for the whole rule, including how a shared connection name is routed to this
instance's own DSN.
With both — wiring rows and a --seed file — the database is authoritative
and the local file may only ADD. A local providers:, field_types:,
attribute_providers: or attributes: entry for an object type or slot the
database never declared is built normally, which is how a kind: csv provider (a
path cannot be shared wiring) and a Go host's hand-written providers survive a
push. A local wiring entry for one the database already declares fails the
boot with APERTURE_WIRING_LOCAL_COLLISION naming it, rather than one side quietly
winning — with one exception: a local inline attributes: block for a slot the
database declares is layered under the pushed row rather than refused, because
the shared layer wins every key both serve and nothing is dropped. See
With both, the database wins and the file may only ADD.
Noticing a push without a restart
The wiring read above happens once, at startup. That is the whole behaviour
unless you ask for more, and for most deployments it is the right one: a
wiring push is picked up by restarting the instances, which is what a deploy
pipeline already does.
--wiring-poll (env APERTURE_WIRING_POLL) turns on a background re-read:
bin/aperture serve --store 'postgres://aperture@db/aperture' --wiring-poll 30s
bin/aperture serve --store 'postgres://aperture@db/aperture' --wiring-poll on
APERTURE_WIRING_POLL=2m bin/aperture serve --store 'postgres://aperture@db/aperture'
| Value | Meaning |
|---|---|
| omitted | off — wired once at boot. No background reader, no periodic query. |
off | off, said out loud. Useful when a variable is inherited and cannot be unset. |
0, 0s | off. A zero interval is "never". |
on | on, at the default interval of 30s. |
a Go duration (45s, 2m) | on, at that interval. |
Anything else — banana, -5m — fails the command with
APERTURE_CONFIG_INVALID naming the setting and the value it rejected, before
any connection is made, so a typo costs nothing but the typo.
When it is on, the instance reports it on stderr at startup and reports each change it sees:
wiring poll: re-reading the shared wiring every 30s; a change will be adopted and reported here
wiring poll: the deployed wiring CHANGED (3f9a1c72 -> 8b40e5de) and this instance
ADOPTED it; decisions already in flight finish on the wiring they started with
What a swap replaces, and what it does not
An adopted change rebuilds everything a decision reads: the object provider registry, the field-type declarations folded into it, the attribute providers, the rules engine over them, the decision engine and the service facade. They are built as one version and installed with a single pointer store, so a decision either sees all of a push or none of it — never half.
- A request pins one version at its entry and finishes on it. A push landing mid-request does not change what that request decides.
- A decision never blocks on a wiring read. The rebuild happens on the poll goroutine; a request pays one atomic load.
- Attribute caches do not survive a swap. Each version gets fresh per-slot
caches, so a rebuilt slot never answers from an entry fetched under the old
configuration's
ttl:— which is the window a revoked clearance would otherwise keep authorizing for. - The connection name set is frozen for the life of the process — see below.
- The local
--seedfile is RE-READ. A rebuild goes through the same builder the boot did, and that builder parses the file from disk again. So the two local sections — inlineobjects:metadata and inlineattributes:bags — and the declared attribute-key sets taken from them are whatever the file says now, not what it said at startup. A file rewritten in place since the boot (a ConfigMap volume remount, a redeploy that only updates a mounted file) therefore takes effect on the next swap instead of needing a restart. That is usually what you want, and it is worth knowing it is not opt-in: the trigger is any shared push, so an unrelated push is what makes a local edit live. - A failed rebuild installs nothing. The instance keeps the wiring it has and goes on deciding, the digest does not advance, and the next tick tries again:
wiring poll: the deployed wiring CHANGED (3f9a1c72 -> 8b40e5de) but this instance
could not adopt it, so it keeps the wiring it has and goes on deciding: [...]
The listener, the authenticator and the HTTP server itself are built once and are untouched by a swap; only what sits beneath them is replaced.
Two digests: the shared set, and this instance's own file
WiringPosture.Digest, and the two short hashes in the lines above, are digests of
the shared wiring — the five tables — and of nothing else. That is exactly the
right handle for "did this instance get the push?", and comparable with what
aperture wiring diff reports. Nothing local is folded into it, ever:
a value mixed with per-instance content would match neither a push nor a wiring
diff.
WiringPosture.LocalDigest is the other half: a digest of the local document
the version this instance is deciding through was built from — its own --seed
file — and empty when it has none, which is every instance wired only from the
shared tables. It exists because a rebuild re-reads that file, and the file
decides things (inline objects: metadata, inline attributes: bags, the declared
key sets taken from them, and on a deployment that has pushed nothing at all the
whole of the wiring). Without it, two instances could report the identical Digest
and still return different verdicts.
So the fleet sweep is on both:
| You compare | You learn |
|---|---|
Digest | whether the same push has landed on both instances |
Digest and LocalDigest | whether the two were built from the same configuration at all |
Two properties of LocalDigest are worth knowing before you build a check on it:
- It tracks the version this instance is RUNNING, not the file as it is on disk right now and not what the process booted on. A local edit is adopted on the next swap (see above), and the digest moves at that moment — which is what makes it honest: it always names the document the current answers are coming from.
- It is a content digest. A reformatted, re-indented or comment-stripped file that means the same thing digests the same, so a pipeline that regenerates the document does not show up as drift. Both digests are hashes and carry no path, no account, no principal and no object data.
The connection name set needs a restart
The shared tables carry a connection's name and nothing else. Which server,
which credential, how big a pool and how long a statement may take are
per-instance facts, and this instance resolves them once, at boot — from a Go
host's seed.WithConnectionOpener, from a connections: entry in its own
--seed file, or from the conventional APERTURE_CONNECTION_<NAME>_DSN. See
Where its wiring comes from.
That makes the name set a boot-time contract between the shared manifest and the routes this instance can supply locally, and not a runtime one. A running process cannot conjure a route for a name that appeared while it was working, and draining a pool for a name that vanished is a different problem from adopting wiring. So the name set is frozen for the life of the process, and a push that changes it in either direction is detected, reported, and not applied:
wiring poll: the deployed wiring CHANGED (3f9a1c72 -> 8b40e5de) but this instance
could not adopt it, so it keeps the wiring it has and goes on deciding:
[APERTURE_WIRING_RESTART_REQUIRED] cli: the deployed wiring changes this
instance's connection NAME SET — it adds connection name "replica" — and that set
is FIXED for the life of a process: [...] RESTART THIS INSTANCE to adopt the push.
Three things follow, and they are the whole of the behaviour:
- The rest of the push is held, not applied. A push is adopted whole or not at all, so the providers, field types and attribute providers deployed alongside a connection change stay outstanding with it. Applying the parts that happen to fit would leave this instance running a wiring version that was nobody's.
- Nothing is torn down. A name the push removed keeps its pool, and this instance goes on deciding through it. The pools belong to the boot and are closed once, on shutdown.
- The instance keeps deciding, and keeps re-reporting. The digest does not advance, so the condition is re-detected on every tick until the push is adopted or corrected — which is the noisy direction on purpose.
The remedy is a restart, after supplying a route for each added name — in that
order, which is why it is a three-step rollout and not a push:
Refreshing wiring on a live fleet.
Read what the deployment expects with
aperture wiring show; the fixups on
APERTURE_WIRING_RESTART_REQUIRED name each added and dropped name, and the ones on
the boot-time APERTURE_WIRING_CONNECTION_UNROUTED list the three routes.
Choosing an interval
The interval is the window a fleet is allowed to disagree with itself: from
the push until this instance re-reads and adopts, it is still answering from the
wiring it booted on. That makes it the same kind of number as an attribute slot's
ttl: — a bound on how long a revoked thing keeps
being honoured — and not a performance knob.
Longer is the more tempting mistake and the worse one. At five minutes a push
reads as having had no effect, and the operator reaches for the rolling restart
that polling exists to remove. Shorter buys nothing measurable: a push is a human
act at human cadence, and a one-second interval has every instance in the fleet
query five tables every second, forever, against wiring that changes perhaps
weekly. The default of 30s sits where readiness probes do, so "within half a
minute of the push" needs no new unit of trust.
There is deliberately no minimum. A very short interval is a cost you can read about here rather than a refusal you cannot override.
What a tick costs, and what it cannot miss
A tick is one GetWiring — the same single, consistent snapshot of the five
tables the boot takes — followed by a comparison of a content digest against
the digest this instance was wired from. The read is full and the comparison is
cheap; nothing is reconstructed to find out whether it needed to be.
A cheaper probe was considered and rejected, because every one available can be wrong in the direction that matters, and a change an instance does not see is an instance that is silently stale while reporting itself healthy:
- A newest-timestamp probe would trust the clock of whichever host ran the push. A push from a host whose clock lags writes rows older than the ones it replaced, and the probe reports "unchanged".
- A row-count probe misses every change that keeps the count — a re-pointed statement, a narrowed declared key set, a renamed connection.
- Four per-section reads can straddle a concurrent push and compose a wiring set that never existed.
The digest ignores the created_at / updated_at stamps on purpose. A push
rewrites every row, so an identical re-push — the same pipeline running twice —
is not a change, and is not reported as one.
What a shutdown does with a tick in flight
SIGINT / SIGTERM cancels the reader at once, so a loop waiting for its next
tick is gone before graceful shutdown even starts. A tick that is already inside a
rebuild is different: a rebuild re-reads the seed file and opens every declared
CSV, and none of that is interruptible. A rebuild that has not begun declines to
start; one that has is waited for, for five seconds, and then abandoned:
wiring poll: a wiring refresh is still in flight; waiting up to 5s for it before
this process exits
wiring poll: the refresh in flight did not finish within 5s, so this process stops
without waiting for it; a wiring version is installed whole or not at all, so it
installed nothing
Five seconds against the ten of graceful shutdown keeps the worst case of the two together inside the 30-second termination grace an orchestrator gives by default — which is the point: before it was bounded, a rebuild on a stalled mount could hold the process open for minutes after the HTTP server had finished draining, and say nothing. Abandoning is safe: a version is installed whole or not at all, so the abandoned rebuild installed nothing, and the next process to start re-reads the wiring from scratch. A shutdown that made no such wait is silent.
Under serve, the facade is wired with everything the other surfaces expect: the
admin gate, delegation and impersonation mutators, the append-only audit trail,
the rules engine over a storage-backed rule source, and the object providers the
wiring declares. A rule saved through the admin UI takes effect on the next
decision with no separate rule store.
Full flags: serve.
Related
- Global options —
--seed/--store. - Refreshing wiring on a live fleet — the same machinery from the operator's side: what a push does to a fleet that is already running, how a stale instance surfaces, and what the swap costs.
- mcp — the read-only stdio surface, for MCP clients rather than HTTP.
- Command-Line Reference — the generated flag table.