Introduction
Aperture is a fine-grained access-control engine for the frankbardon/*
family of services. It answers one question — "is this principal allowed to do
this thing to this resource, and why?" — consistently across every surface that
asks it.
Library-first
Aperture is library-first: the public Go packages at the module root
(github.com/frankbardon/aperture) are the product. Every other surface — the
aperture CLI, the Twirp/HTTP RPC API, the MCP server, and the admin UI — is a
thin translator over a single decision engine. There is exactly one place a
decision is made, so the answer a script gets from the CLI is the same answer a
service gets over RPC and an agent gets over MCP.
The decision API
The engine exposes three operations, each available in a single and a bulk-batched form:
| Operation | Question it answers |
|---|---|
| Check | May this principal perform this action on this resource? |
| Enumerate | Which resources/actions is this principal allowed? |
| Explain | Why was a decision reached — which rules and grants applied? |
Explain is a first-class citizen, not a debugging afterthought: access
decisions are auditable by construction.
What's inside
Aperture models principals (identity), the resource/object model (model,
scope), and object providers (provider) that resolve live attributes.
Decisions flow through the engine, driven by a rules layer (rules) that
compiles a rule AST to an expr-lang/expr
expression and evaluates it in-process — pure-Go, no external policy service.
Grants come in several flavors (direct, delegation, impersonation), reads
are narrowed by scoped visibility (filter), and every decision can be recorded
to an audit log (audit). Persistence sits behind one Storage interface with
a hand-written SQL / modernc.org/sqlite implementation and an in-memory twin.
Design tenets
- Pure-Go,
CGO_ENABLED=0end to end. No CGO, no external policy engine. - Coded errors. Every failure is an
APERTURE_*error carrying a stable code and an actionable fixup — never a bare string. - No cross-account leakage. Decisions and error messages never expose data from an account the caller cannot see.
- One engine, many surfaces. CLI, RPC, MCP, and UI are adapters; the decision logic lives once, in the library.
Where to go next
- Getting Started — install Aperture and run your first
check. - CLI & Library — the
aperturecommand tree and the Go embedding API. - Service Surfaces — the Twirp/HTTP RPC API, MCP server, and admin UI.
- Concepts — identities, the object model, scopes, rules, grants, and audit.
- Reference — error codes, configuration, and the CLI/RPC surface tables.
- Operations — deployment, storage, and running the engine in production.
- Internals — architecture, package layout, and extension points.
- Contributing — conventions and the Update-Demand rule.
Getting Started
This section is the on-ramp. In four short pages you install Aperture, learn the vocabulary the rest of the book assumes, and make your first access-control decision two ways — from the command line and from Go.
Everything here runs against Aperture's embedded example model, a
self-contained fixture for the tenant acme. You need nothing but the built
binary; there is no store to provision and no data to load.
Read in order
- Installation — build
bin/aperturefrom source (Go 1.26.1, pure-Go,CGO_ENABLED=0). - Concepts primer — principals, actions, objects, patterns and specificity, rules, scopes, and accounts: enough to read any later chapter.
- First decision (CLI) — a runnable
aperture checkwalkthrough showing an allow, a default deny, and a deny-override. - Library quickstart — the same decision from a minimal Go program embedding the engine.
Already know what Aperture is? Skip to Installation. Want the big picture first? See the Introduction.
Installation
Aperture ships as a single, statically linked binary with no runtime
dependencies. It is pure Go with CGO_ENABLED=0 end to end — there is no C
toolchain, no external policy engine, and nothing to install alongside it. Build
it from source and you get one file, bin/aperture, that runs anywhere the Go
build targets.
Prerequisites
| Requirement | Version | Notes |
|---|---|---|
| Go | 1.26.1 | The module targets this toolchain; earlier compilers may reject newer language/std usage. |
make | any | The Makefile wraps the build flags; you can also call go build directly. |
| C compiler | none | CGO_ENABLED=0 is enforced — there is nothing to link. |
Build from source
Clone the repository and build the binary with the provided target:
git clone https://github.com/frankbardon/aperture.git
cd aperture
make build
make build compiles the aperture command with CGO_ENABLED=0,
-trimpath, and -ldflags="-s -w", writing the result to bin/aperture.
Confirm the binary works:
bin/aperture --version
aperture version <version>
Build directly with go
If you prefer not to use make, the equivalent invocation is:
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o bin/aperture ./cmd/aperture
The cmd/aperture/main.go entrypoint is a thin adapter: it only stamps the
version and hands off to the CLI command tree. All behavior lives in the
library packages at the module root.
Put it on your PATH (optional)
The examples in this book invoke the binary as bin/aperture from the
repository root. To run aperture from anywhere, copy the binary onto your
PATH:
cp bin/aperture /usr/local/bin/aperture
aperture --version
Verify the toolchain
Aperture keeps CGO_ENABLED=0 and stays free of any CGO dependency (no geo/h3,
no SQLite C bindings — the SQLite backend uses the pure-Go modernc.org/sqlite).
If a build fails complaining about a C compiler, an environment variable is
forcing CGO on; clear it and rebuild:
CGO_ENABLED=0 make build
Where to go next
- Concepts — the vocabulary the rest of the book assumes.
- First decision (CLI) — run
aperture checkagainst the built-in example model. - Library quickstart — embed the decision engine in a Go program.
Concepts primer
Aperture answers one question — "is this principal allowed to do this action to this object, and why?" This page defines the vocabulary that question is built from. It is deliberately brief: enough to read any later chapter. Each term links forward to its full treatment in the Concepts section of the book.
Every example on this page is drawn from the committed example model (account
acme), the same fixture the First decision (CLI)
walkthrough uses.
Principal
A principal is the actor a decision is made for — a user, a service, or
any other identity that can attempt an action. In the example model, alice
and bob are principals. Each principal has a stable id, a kind, an identity
string (such as user:alice), and a set of roles and group memberships that
carry its grants. The identity package owns this model.
Action
An action is the verb being attempted — read, write, delete, share.
Actions are declared per object type: a document in the example model permits
read, write, delete, and share. A decision always names exactly one
action.
Object
An object is the resource an action targets, named by a canonical object-identity string. Identities are hierarchical, path-like, and always rooted at an account. In the example model a document is written as:
account:acme/project:atlas/document:42
Each type:id segment narrows the path. This structure is what lets a single
grant cover a whole subtree while a more specific grant overrides it for one
object. The model package defines object types and identities; live object
attributes are resolved by object providers (the provider package).
Identity patterns and specificity
A pattern is an object-identity with wildcards, used by a grant to cover many
objects at once. ** matches any remaining path; a plain segment matches
exactly. In the example model:
account:acme/project:atlas/** # every object under project atlas
account:acme/project:atlas/document:secret # exactly one document
Specificity decides which grant wins when several match. A more specific
pattern (one that matches fewer objects) outranks a broader one. That is how the
example model seals a single document: a broad allow on
account:acme/project:atlas/** lets engineering read everything, while a
narrower deny on account:acme/project:atlas/document:secret overrides it for
that one object. Deny-overrides plus specificity is the core resolution rule.
Grant
A grant binds a subject (a principal, role, or group), a permission (an
object-type + action pair), and an object pattern, with an effect of allow
or deny. Grants are the raw material of every decision. They come in several
flavors: direct grants, time-bounded delegations (delegation), and
impersonation grants that let one principal act as another (impersonation).
Rule
A rule is a conditional predicate that gates a grant on the attributes of the
request — the principal, the object, or the environment. Rules are authored as an
AST that Aperture compiles to an expr-lang/expr
expression and evaluates in-process; there is no external policy service and no
Pulse dependency. The rules package owns compilation and caching.
Scope
A scope narrows what a principal can see or act on within an account —
Aperture's mechanism for row-level and subtree-level visibility. Scoped reads are
narrowed by the filter package so a listing never returns objects the caller is
not entitled to see. The scope package defines the scoping model.
Account
An account is the top-level tenant boundary. Every object identity is rooted
at an account (account:acme/...), every grant is stamped to an account, and
every decision is scoped to one active account. Accounts are hard isolation
boundaries: a decision or an error message never leaks data from an account the
caller cannot see. Principals join an account through a membership, which the
engine can optionally enforce (a non-member is denied). Accounts are modeled as
entities in the model package.
How they fit together
A Check takes an account, a principal, an action, and an
object. Aperture gathers every grant whose subject includes the principal
and whose pattern matches the object, evaluates any attached rules, applies
deny-overrides by specificity, and returns a verdict plus a reason naming
the deciding grants. The same resolution drives Enumerate (which objects a
principal may act on) and Explain (the full decision trace).
Where to go next
- First decision (CLI) — see these terms resolve into a real verdict.
- Library quickstart — ask the same question from Go.
- The Concepts section of this book expands each term above into its own chapter (identities, the object model, patterns and specificity, rules, scopes, grants, and accounts).
First decision (CLI)
This walkthrough makes real access-control decisions with the aperture binary.
It needs nothing beyond the build from Installation: every
command below runs against Aperture's embedded example model — a
self-contained fixture for the account acme — so there is no store to set up
and no data to load.
The example model
When you do not pass --seed, aperture check loads the committed example
fixture. It models one tenant, acme, with a project atlas full of documents,
and three grants:
| Grant | Subject | Effect | Object pattern |
|---|---|---|---|
g-eng-read-atlas | group engineering | allow read | account:acme/project:atlas/** |
g-editor-write-atlas | role editor | allow write | account:acme/project:atlas/** |
g-deny-secret-read | group engineering | deny read | account:acme/project:atlas/document:secret |
alice is an editor in engineering; bob is a viewer in engineering. That is
enough to see an allow, a default deny, and a deny-override.
The command
aperture check <principal> <action> <object>
check takes three positional arguments and prints a one-word verdict plus a
reason. The process exit code carries the verdict — 0 for allow, non-zero
for deny — so a check composes in a shell pipeline
(aperture check … && deploy).
Relevant flags:
| Flag | Default | Purpose |
|---|---|---|
--seed | embedded example | Path to a JSON/YAML seed model to decide against. The embedded fixture is the default because --store is omitted here; with a --store DSN, omitting --seed seeds nothing. |
--account | acme | The active account the decision is scoped to. |
--store | in-memory | DSN for a persistent backing store. |
The full flag reference lives in the CLI chapter later in this book; the three above are all this walkthrough needs.
Allow: engineering reads a document
alice is in engineering, and g-eng-read-atlas lets engineering read
everything under atlas:
bin/aperture check alice read account:acme/project:atlas/document:42
allow
reason: allowed by grant g-eng-read-atlas (allow account:acme/project:atlas/**) at specificity 39300; 1 matching grant(s) considered
The command exits 0. The reason names the deciding grant and its
specificity — a broad ** pattern scores low.
Allow: an editor writes
alice also holds the editor role, so g-editor-write-atlas permits a write:
bin/aperture check alice write account:acme/project:atlas/document:42
allow
reason: allowed by grant g-editor-write-atlas (allow account:acme/project:atlas/**) at specificity 39300; 1 matching grant(s) considered
Default deny: no grant matches
bob is only a viewer — nothing grants him write — so the decision falls through
to a fail-closed default deny:
bin/aperture check bob write account:acme/project:atlas/document:42
deny
reason: default deny: no grant matched action "write" on "account:acme/project:atlas/document:42" for principal "bob" in account "acme"
The command exits non-zero. A decision with no matching grant is always a deny — Aperture fails closed.
Deny by override: specificity wins
g-deny-secret-read seals one document. Even though g-eng-read-atlas would
allow the read, the deny is more specific and overrides it:
bin/aperture check alice read account:acme/project:atlas/document:secret
deny
reason: denied by grant g-deny-secret-read (deny account:acme/project:atlas/document:secret) at specificity 60300; 2 matching grant(s) considered
Two grants matched; the deny at specificity 60300 outranks the allow at
39300. This is deny-overrides by specificity — see the
Concepts primer.
Why? — explain
check gives the verdict; explain gives the whole trace. It takes the same
three arguments:
bin/aperture explain alice read account:acme/project:atlas/document:secret
Explain alice/read on account:acme/project:atlas/document:secret in account acme
subjects: principal:alice, role:editor, group:engineering
grants considered (3):
g-eng-read-atlas [allow account:acme/project:atlas/**] allow covers the object via literal scope at specificity 39300
g-editor-write-atlas [allow account:acme/project:atlas/**] action "write" does not match the requested "read"
* g-deny-secret-read [deny account:acme/project:atlas/document:secret] deny covers the object via literal scope at specificity 60300
verdict: DENY (top specificity 60300)
reason: denied by grant g-deny-secret-read (deny account:acme/project:atlas/document:secret) at specificity 60300; 2 matching grant(s) considered
The * marks the deciding grant. explain is a first-class operation, not a
debug afterthought — every decision is auditable by construction.
A note on the acting principal
For check, enumerate, and explain, the principal is a positional argument
— the subject of the question. The write commands (put, bestow,
revoke, impersonate, …) are different: they need to know who is acting, and
take that principal from the --principal flag or the APERTURE_PRINCIPAL
environment variable. For example, bestow reads the delegating principal from
APERTURE_PRINCIPAL:
export APERTURE_PRINCIPAL=alice
bin/aperture bestow --help
Setting APERTURE_PRINCIPAL once in your shell saves passing the flag to every
mutation. The read decisions above never consult it — they ask about a principal
you name explicitly.
Where to go next
- Library quickstart — make the same decision from Go.
- Concepts primer — the vocabulary behind the verdicts.
- The CLI section of this book documents every command and flag in full.
Library quickstart
Aperture is library-first: the CLI, the RPC API, the MCP server, and the admin UI are all thin adapters over the same Go packages. This page embeds the decision engine directly and asks it the same question the First decision (CLI) walkthrough asked — this time from Go.
The pieces
A decision needs three collaborators, wired by hand (Aperture uses manual dependency injection — no wire/fx/dig):
| Package | Role |
|---|---|
storage/memory | A backing store for the model. The in-memory implementation is ideal for a demo; storage/sqlite is its persistent twin behind the same interface. |
engine | The decision engine. engine.New(store) binds it to a store. |
service | The facade every surface calls. service.New(engine.New(store)) gives you Check / Enumerate / Explain. |
seed | Loads a model into a store. seed.Example is the embedded acme fixture. |
A minimal program
Save this as main.go inside a module that requires
github.com/frankbardon/aperture:
package main
import (
"context"
"fmt"
"log"
"github.com/frankbardon/aperture/engine"
"github.com/frankbardon/aperture/seed"
"github.com/frankbardon/aperture/service"
"github.com/frankbardon/aperture/storage/memory"
)
func main() {
ctx := context.Background()
// Build an in-memory store and load the embedded example model (account "acme").
store := memory.New()
if err := store.Setup(ctx); err != nil {
log.Fatal(err)
}
if err := seed.Load(ctx, store, seed.Example, seed.FormatYAML); err != nil {
log.Fatal(err)
}
// The service facade is the single entry point every surface uses.
svc := service.New(engine.New(store))
res, err := svc.Check(ctx, service.Query{
Account: seed.ExampleAccount, // "acme"
Principal: "alice",
Action: "read",
Object: "account:acme/project:atlas/document:42",
})
if err != nil {
log.Fatalf("check failed: %v", err)
}
fmt.Printf("allow=%v\n", res.Allow)
fmt.Printf("reason: %s\n", res.Reason)
fmt.Printf("deciding grants: %v\n", res.DecidingGrantIDs)
}
Run it:
go run .
allow=true
reason: allowed by grant g-eng-read-atlas (allow account:acme/project:atlas/**) at specificity 39300; 1 matching grant(s) considered
deciding grants: [g-eng-read-atlas]
That is the identical decision, reason, and deciding grant the CLI printed — because it is the same code path. There is one engine; every surface is a translator over it.
The request and result types
service.Check takes a service.Query and returns a service.Result:
type Query struct {
Account string // active account the decision is scoped to
Principal string // id of the principal asking
Action string // the verb being attempted
Object string // canonical object-identity string
}
type Result struct {
Allow bool // the verdict
Reason string // human-readable explanation
DecidingGrantIDs []string // grant ids that produced the verdict
}
Result never surfaces raw errors as denies inconsistently: an operational
failure fails closed (Allow: false) with the cause in Reason, while a
malformed request returns a non-nil error carrying an APERTURE_* code. Recover
that code with errors.CodeOf from the errors package.
Errors are coded
Every failure Aperture returns across a package boundary is an APERTURE_* coded
error, not a bare string. When Check returns a non-nil error — for example, a
malformed object identity — inspect the code rather than the message:
import aerr "github.com/frankbardon/aperture/errors"
if err != nil {
switch aerr.CodeOf(err) {
case aerr.APERTURE_INVALID_INPUT:
// the query was malformed — fix the caller
default:
// something operational went wrong
}
}
Beyond Check
The facade exposes the whole read API — Enumerate (which objects a principal
may act on), Explain (the full decision trace), and batch forms of each —
plus, when constructed with the right options, the mutation path (entity CRUD,
grants, delegation, impersonation). A read-only service.New(eng) returns
APERTURE_UNIMPLEMENTED from any mutation, so a decision-only surface stays
minimal.
Where to go next
- Concepts primer — the vocabulary behind
QueryandResult. - The CLI & Library section of this book covers the full Go embedding API.
- The Reference section lists every
APERTURE_*error code and its fixups.
CLI overview
Audience: operators and integrators driving Aperture from a shell.
aperture is the command-line face of the same decision engine the
library, the RPC/HTTP server, and the MCP surface all
call. Every subcommand is a thin adapter: it parses flags, hand-wires the
storage → engine → service graph, makes exactly one call into the library, and
maps the result to output plus an exit code. There is no CLI-only behaviour — a
check from the shell resolves through the identical code path a Check RPC
does.
This part of the book is the narrative CLI guide, grouped by what each command
family is for. It links into the generated
Command-Line Reference, which is produced from the live
urfave/cli command tree and holds the authoritative, per-command flag tables.
When you want the exact flags for a command, follow the link on that command's
name — this guide never re-tabulates them, so the two never drift.
Command families
| Family | Commands | What it does |
|---|---|---|
| Decisions | check, enumerate, search, explain, identifiers | Ask and audit access-control questions (read-only). |
| Mutations | put, get, list, delete, bestow, revoke, impersonate | Read and change the model — entities, grants, delegation, impersonation. |
| Provisioning | template, bulk | Apply parameterized templates and transactional bulk grant/revoke. |
| Portability | export, import | Serialize the whole model to a state file and apply it back. |
| Attributes | attributes | Inspect the host directories a rule reads principal.* / account.* from, read one, and drop cached bags. |
wiring | wiring | Deploy, read, snapshot and diff the four seed sections a whole deployment shares out of its database. |
serve | serve | Run the HTTP + Twirp server and admin UI. |
mcp | mcp | Serve the read-only MCP surface over stdio. |
The embedded example model
Every command runs against a model. When you pass neither --seed nor --store,
Aperture loads a committed example fixture — a self-contained model for the
account acme — so the read commands below work with no setup. The fixture,
its grants, and its principals are walked through in
First decision (CLI). The examples in
this guide use that fixture unless they say otherwise, and never reference data
from any other account.
Two ways to select a model
The commonly shared options — --seed, --store, --account, and
--principal — are defined per command, not as root persistent flags, and
they mean the same thing everywhere they appear. They are documented once in
Global options; each family page below links back to that
page rather than re-explaining them.
Related
- Global options —
--seed/--store/--account/--principal. - Command-Line Reference — the generated per-command flag tables.
- First decision (CLI) — the example model, end to end.
- Library overview — the same engine, embedded in Go.
Global options
Audience: operators and integrators driving Aperture from a shell.
aperture declares no persistent global flags. The five options that recur
across the command tree — --seed, --store, --account, --principal, and
--enumerate-limit — are defined per command, so they appear in each
command's flag table in the Command-Line Reference. They
carry the same meaning wherever they appear; this page is the single explanation
the family pages link back to.
Selecting a model: --seed and --store
Every command resolves its model from these two options, in this order:
| Option | Default | Meaning |
|---|---|---|
--seed | see below | Path to a JSON/YAML seed model. When given, the document is applied to the store on every invocation. |
--store | in-memory | DSN for a persistent backing store: a postgres:// / postgresql:// URL for PostgreSQL, any other value as a SQLite path. When omitted, an in-memory store is built and seeded, then discarded when the command exits. |
Use --seed to point at your own model file for a one-shot decision, and
--store when you want changes to persist across invocations.
What an omitted --seed means
It depends on --store, and the difference is deliberate.
--store | --seed omitted |
|---|---|
| omitted (in-memory) | the committed example fixture is loaded — this is the zero-flag demo |
a SQLite path or a postgres:// URL | nothing is seeded; the model already in that database is used as it stands |
Applying a seed document upserts the entire model — accounts, principals,
roles, groups, grants, rules. On an in-memory store there is nothing to
overwrite and the fixture is the only model there could be. On a database there
usually is: it is somebody's production model, or a model a sibling instance
provisioned. So Aperture never guesses. Writing model state is an explicit act —
--seed, import, or a mutation command — and never a side
effect of starting up.
That is what lets a second instance boot against a database it does not own:
# Reads the model another process provisioned. Writes nothing on startup.
bin/aperture serve --store 'postgres://aperture@db/aperture'
# Decide against a model file, no persistence:
bin/aperture check alice read account:acme/project:atlas/document:42 \
--seed ./my-model.yaml
# Provision a SQLite store from a document, then persist mutations to it:
bin/aperture put grant --principal root --account acme \
--store ./aperture.db --seed ./my-model.yaml --file ./grant.json
Note that --seed is re-applied on every invocation, not just the first, so a
multi-step session against a persistent store should pass the document once and
then drop the flag — otherwise each step's deletions are undone by the next
step's seed.
It means the same thing for wiring
A seed document carries two kinds of section, and the table above is about the
first: the ten model-state sections, which --seed applies to storage. The
other six are runtime wiring — providers:, objects:, field_types:,
connections:, attributes: and attribute_providers: — which say where a
decision reads object metadata and subject attribute bags from. Those are never
written to storage by --seed, so the file is read a second time for them.
An omitted --seed means the same thing for both halves:
--store | --seed omitted |
|---|---|
| omitted (in-memory) | the example fixture supplies the wiring too |
a SQLite path or a postgres:// URL | no local wiring; the instance is wired by the shared wiring tables if they hold rows, and by nothing if they do not |
So aperture serve --store 'postgres://aperture@db/aperture' with no --seed
reads its object providers, field types and attribute slots out of the database
it opened. When those tables are empty — which is 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 for this and nothing to configure.
With both, the database wins and the file may only ADD
An instance can have wiring rows and a --seed file, and most will: some wiring
cannot be shared at all. A kind: csv provider's data source is a filesystem
path, so aperture wiring push refuses one outright, and a Go host's
hand-written providers are code no document describes.
So the two compose, additively:
- The database is authoritative. Every pushed entry is built.
- The local file may ADD an object type or an attribute slot the database
never declared. It is built exactly as it would be on an instance that has
never been pushed to,
kind: csvand all. - A local wiring entry for an object type or slot the database already
declares fails the boot with
APERTURE_WIRING_LOCAL_COLLISION, naming the entry and both sections. - The two inline data sections are the exception, and they answer differently
because the two seams resolve an overlap differently. A local
objects:entry for a type the database serves is refused (APERTURE_CONFIG_INVALIDnaming the type): the object rule is a type-level discard, so a push on another host would switch off metadata checked into this instance's file. A localattributes:block for a slot the database declares is layered under it, not refused — the pushed row is the slot's shared layer, the inline bags its local one, the shared layer wins every key both serve and nothing is dropped, so a contested key reads the same on every instance. See Precedence: two layers, and the shared layer wins.
The collision is refused rather than resolved because both resolutions are silent and both change what a decision reads: the database winning would discard wiring somebody checked into this instance's file, and the file winning would mean one instance in the fleet answers from a source its peers cannot see. Neither shows up as an error later — it shows up as a different verdict. Fix it by deleting the local declaration, or by pushing a wiring document that omits the shared one.
connections: is the exception, in both directions: a local entry under a shared
name is that name's route (see below) and not a competing declaration, and a
local entry under a name the shared manifest never mentions is the route for a
connection only this instance's own added providers reach. Both are carried.
A host that registers its own providers in Go gets the same answer from the same
place — provider.Registry.Register refuses a second provider for a type it
already serves, with APERTURE_PROVIDER_INVALID. The rule is about the registry,
not about which syntax declared the entry.
A shared connection carries its name and nothing else: no DSN, no credential,
not even the dsn_env: variable name. Each instance supplies its own route for a
name, in one of three ways — a Go host's seed.WithConnectionOpener, a
connections: entry under the same name in this instance's own seed file, or the
conventional environment variable APERTURE_CONNECTION_<NAME>_DSN (every
character that is not a letter or digit becomes an underscore, so main reads
APERTURE_CONNECTION_MAIN_DSN). A name with no route at all refuses the boot with
APERTURE_WIRING_CONNECTION_UNROUTED, naming the connection and the variable it
wanted — every unrouted name in one refusal, so a new instance is configured in one
pass rather than one restart per connection.
The wiring is read once, unless you ask for more
The read above happens at startup and only at startup. A aperture wiring push
from another host is picked up by restarting the instances — which is what a deploy
pipeline does anyway, and it is the whole story for a deployment with one instance.
A long-lived instance can be told to notice a push and adopt it instead, with
serve --wiring-poll or
APERTURE_WIRING_POLL. It is off unless configured, and off means no
background reader and no periodic query, so an instance that cannot use it pays
nothing for it. When it is on, an adopted push rebuilds the provider registry, the
field types and the attribute providers as one version, a request pins one version
at its entry, and the connection name set stays frozen for the life of the
process — see
What a swap replaces.
Unlike --enumerate-limit, it is not carried by every command that decides:
it configures a process that outlives a decision, and there is no tick in the life
of aperture check for one to happen on. It is declared on serve, and the whole
account of it — the interval vocabulary, how a change is detected, what it costs,
and what a shutdown does with a refresh still in flight — is on that page.
An instance will not start on wiring it cannot construct
A pushed entry whose kind: this instance cannot build from a row refuses the boot
with APERTURE_WIRING_KIND_UNSHAREABLE, naming the object type or the attribute
slot. In practice that is a stored kind: csv — aperture wiring push refuses one,
so reaching a boot means the row was written by hand or by an older build, and there
is no path: to add because the shared tables have no column for one. The other
refusals a boot can raise come from the seed builders and already name the entry
they refused: an incomplete statement set, an unparseable ttl:, a references:
target no provider serves, a field_types: word outside date/datetime.
Refusing to start is the deliberate choice, and degrading is not the gentler
option. An object type with no working provider does not error on a decision — a
rule reading object.tier reads a missing path. An attribute slot with no working
provider is worse: the leniency contract collapses the
failure to an empty bag, so a rule that excluded on an attribute it can no longer
see stops excluding, and the grant widens. Nothing in the resulting verdict,
trace or note says a provider was missing. A per-decision refusal was rejected for
the same reason: it would let a misconfigured instance keep serving traffic for the
types it happened to have, so two instances that look identically configured answer
one question two ways.
Scoping a decision: --account
--account names the active account a decision or mutation is scoped to. Its
behaviour differs by command family:
- On the decision commands (
check,enumerate,explain),--accountdefaults toacme(the example account) and bounds which grants the decision considers. - On mutation, provisioning, and portability commands,
--accounthas no default and is the active account used to resolve the acting principal's admin tier (account-admin vs system-admin). Several of those commands require it.
Aperture never lets one account's data surface in another account's decision, and error messages never leak cross-account detail.
The acting principal: --principal
This is the option most worth getting right.
On the read decision commands, the principal is a positional argument — the subject of the question you are asking:
bin/aperture check alice read account:acme/project:atlas/document:42
# ^^^^^ the subject principal, positional — NOT --principal
On the write / mutation commands (put, delete, bestow, revoke,
impersonate, template, bulk, export, import), --principal is a
flag that names the authenticated caller performing the mutation — who is
acting, not who is being asked about. It is sourced from the
APERTURE_PRINCIPAL environment variable, so you can set it once per shell:
export APERTURE_PRINCIPAL=root
bin/aperture put role --account acme --file ./role.json
bin/aperture delete grant g-old --account acme
A mutation with no --principal (and no APERTURE_PRINCIPAL) fails with
APERTURE_UNAUTHENTICATED. A few commands name the acting principal with a
purpose-specific flag instead — bestow/revoke use --delegator, and
impersonate uses --operator — but each of those still reads from
APERTURE_PRINCIPAL as its default source.
The deployment's enumeration ceiling: --enumerate-limit
--enumerate-limit (env APERTURE_ENUMERATE_LIMIT, default 1000) is the
maximum number of object ids one enumeration may return, and the ceiling a
larger request limit is clamped down to. It also bounds the scope member
gather, so one number governs both halves of an enumeration.
It configures the process, not the command. Every command that decides
carries it — check, enumerate, identifiers, explain, serve, mcp —
and all of them resolve it identically, so one binary can never answer 1500
over HTTP and 1000 at the shell. Set it once in the environment:
export APERTURE_ENUMERATE_LIMIT=1500
bin/aperture enumerate alice list 'account:acme/**' --seed ./my-model.yaml
bin/aperture serve --seed ./my-model.yaml
The flag wins over the variable when both are given. A value that is not a whole
number greater than zero — banana, 0, -5 — fails the command with
APERTURE_CONFIG_INVALID naming the setting and the value it rejected, rather
than silently falling back to 1000. To get the default, omit the setting.
On enumerate this is distinct from --limit, which caps a single request
and is itself clamped by this ceiling. See Decisions.
Related
- Command-Line Reference — every command's full flag table.
- Decisions — where the principal is positional.
- Mutations — where
--principalis the acting caller. - First decision (CLI) — the note on subject vs actor, worked through.
Decisions
Audience: operators and integrators asking and auditing access-control questions from a shell.
The decision commands are the read-only core of the CLI. They never change the
model — they ask a question of it. All three of check, enumerate, and
explain take the subject principal as a positional argument (the principal
the question is about), not a --principal flag; see
Global options for why the
write commands differ. identifiers inspects an object type's source.
All four build the same decision stack aperture serve does, so a question asked
in a shell resolves exactly as it does over HTTP — see
Rule-backed permissions.
check — decide one question
aperture check [options] <principal> <action> <object>
check prints a one-word verdict (allow / deny) and a reason, and carries
the verdict in its exit code — 0 for allow, non-zero for deny — so it
composes in a pipeline (aperture check … && deploy).
bin/aperture check alice read account:acme/project:atlas/document:42
allow
reason: allowed by grant g-eng-read-atlas (allow account:acme/project:atlas/**) at specificity 39300; 1 matching grant(s) considered
A question with no matching grant is always a deny — Aperture fails closed:
bin/aperture check bob write account:acme/project:atlas/document:42
deny
reason: default deny: no grant matched action "write" on "account:acme/project:atlas/document:42" for principal "bob" in account "acme"
Full flags: check.
Rule-backed permissions decide the same here as on the server
check, enumerate, identifiers and explain build the same decision
stack aperture serve does: the object metadata declared in the seed's
providers: and objects: sections, the rules engine over the stored rules, and
scope resolution. A permission whose scope strategy is rule-backed
(inclusive;rule=… / exclusive;rule=…) is therefore evaluated identically from
the shell and over HTTP.
This was not always true. Before the shared stack landed, the one-shot commands
wired no rules engine, so a rule-backed permission had no evaluator, the scope
resolver reported APERTURE_SCOPE_RULE_UNCONFIGURED and the fail-closed policy
turned that into a deny. If you scripted against the old behaviour, those checks
now return the verdict the rule implies — which may be allow.
explain — why a decision resolved
aperture explain [options] <principal> <action> <object>
explain takes the same three arguments as check and prints the whole
decision trace: the subject set, every grant considered, why each did or did not
apply, and the deciding grant (marked *). It is a first-class operation, not a
debug afterthought.
bin/aperture explain alice read account:acme/project:atlas/document:secret
Explain alice/read on account:acme/project:atlas/document:secret in account acme
subjects: principal:alice, role:editor, group:engineering
grants considered (3):
g-eng-read-atlas [allow account:acme/project:atlas/**] allow covers the object via literal scope at specificity 39300
g-editor-write-atlas [allow account:acme/project:atlas/**] action "write" does not match the requested "read"
* g-deny-secret-read [deny account:acme/project:atlas/document:secret] deny covers the object via literal scope at specificity 60300
verdict: DENY (top specificity 60300)
reason: denied by grant g-deny-secret-read (deny account:acme/project:atlas/document:secret) at specificity 60300; 2 matching grant(s) considered
When a grant's scope is decided by a rule, the trace also carries the evaluation notes that rule recorded — a metadata field read with the wrong shape, or a match that happened only because the field was absent:
evaluation notes (1):
g-viewer-read [rule public-documents]: object.tags: expected collection, got string
Notes are diagnostic only; they never change a verdict, and they name paths and shapes, never metadata values.
Full flags: explain.
enumerate — list objects a principal may act on
aperture enumerate [options] <principal> <action> <pattern>
enumerate turns the question around: instead of one object, it lists the
object ids under a <pattern> that the principal may take <action> on, one id
per line. --limit caps the result count for this request, within the
deployment's own ceiling — see
--limit and the deployment's ceiling.
Enumeration expands objects from the
object sources the model declares — providers: (a file- or database-backed
provider per type) and objects: (metadata declared inline) — so a model with
neither (like the embedded example) yields an empty list. Run enumerate against
a seed that declares an object source for the type.
bin/aperture enumerate alice read 'account:acme/project:atlas/document:*' \
--seed ./model-with-providers.yaml --limit 100
--limit and the deployment's ceiling
Two numbers bound one enumeration, and they are not the same kind of thing:
| Set by | Governs | Asks | |
|---|---|---|---|
| Request limit | --limit | this one command | how many ids you want back |
| Deployment ceiling | --enumerate-limit / APERTURE_ENUMERATE_LIMIT, default 1000 | the whole process | how many ids anyone may ask this deployment for |
The ceiling always wins:
- A
--limitabove the ceiling is clamped down to it. - A
--limitat or below the ceiling is honoured as asked. - A
--limitof zero or less asks for the ceiling. It does not mean "unlimited", and there is no spelling that does —enumeratenever materialises an unbounded set.
# ask for 100 of the deployment's 1000
bin/aperture enumerate alice read 'account:acme/project:atlas/document:*' \
--seed ./model.yaml --limit 100
# ask for 5000 against the default ceiling: you get 1000, not 5000
bin/aperture enumerate alice read 'account:acme/project:atlas/document:*' \
--seed ./model.yaml --limit 5000
# raise the ceiling, then ask for all of it
bin/aperture enumerate alice read 'account:acme/project:atlas/document:*' \
--seed ./model.yaml --enumerate-limit 2000 --limit 2000
The ceiling is the same number aperture serve honours — it configures the
process, not the command — so the CLI and the server can never be bounded
differently. check, explain, identifiers and mcp carry the same flag for
that reason. See
The deployment's enumeration ceiling.
A full-looking result may be a truncated one. Nothing in the output marks the
difference: a list that stops at the ceiling looks exactly like a population that
happened to end there. When a result comes back holding exactly its effective
bound the engine logs a WARN naming that bound, so an unexpectedly round result
is a reason to check the log and re-ask with a higher --enumerate-limit. The
warning is a hint, not proof — a complete set of that size raises it too.
--limit is not the cheap knob. It shortens only the second half of the
work: the candidate set is gathered against the ceiling first, and --limit
then caps what survives the access decision. --limit 10 against a ceiling of
1000 still pays for ~1000 candidates. The number that moves the cost is
--enumerate-limit, and Performance
carries the measured price of raising it.
A value for --enumerate-limit that is not a whole number greater than zero —
banana, 0, -5 — fails the command with APERTURE_CONFIG_INVALID naming the
setting and the value it rejected, before any store is opened. To get the
default, omit it.
Narrowing by object metadata
--field and --fields-json filter the listing by the objects' metadata —
"which of the datasets I may list carry brand Y?". They apply on top of the
access decision, so they can only ever remove objects from the list; an object
a check would deny is never returned however the predicate is written.
The predicate is typed: a field matches only when its value equals the wanted
value and is of the same kind, so the string "5" never matches the number
5. A shell flag only carries a string, and guessing which strings are "really"
numbers would make enumerate return objects check then denies — so there are
two flags rather than one, and each says what it sends:
| Flag | Sends | Notes |
|---|---|---|
--field key=value | always a string | Repeatable. Everything after the first = is the value, so --field expr=a=b wants "a=b". |
--fields-json '{…}' | a JSON object — real numbers, bools, lists | Use it when the metadata value genuinely is not a string. |
Both may be given. --fields-json is merged first and --field entries
then override it by key, so a stored JSON body can be reused with one value
swapped from the shell.
# a string field
bin/aperture enumerate alice list 'account:acme/**' --seed ./model.yaml \
--field tier=premium
# a list field, matched by MEMBERSHIP — "datasets carrying brand Y"
bin/aperture enumerate alice list 'account:acme/**' --seed ./model.yaml \
--field brands=brand:Y
# seats is a NUMBER, so it needs the JSON spelling; --field seats=5 matches nothing
bin/aperture enumerate alice list 'account:acme/**' --seed ./model.yaml \
--fields-json '{"seats":5,"active":true}'
# merged: seats from JSON, tier overridden from the shell
bin/aperture enumerate alice list 'account:acme/**' --seed ./model.yaml \
--fields-json '{"seats":5,"tier":"basic"}' --field tier=premium
The rules the listing obeys:
- Predicates are ANDed — every one must hold.
- A list-valued field matches by membership; a whole list in
--fields-jsonis a container compared by equality. - A field the object does not carry never matches — not even against
null. - Filtering happens before
--limit.--field tier=premium --limit 10gives the first ten premium objects, not the premium ones among the first ten candidates.
A malformed predicate is a usage error (APERTURE_INVALID_INPUT) naming the
offending text — a --field with no =, an empty key, a --fields-json that is
not JSON or is JSON but not an object. It is never silently skipped: a dropped
predicate would widen the result, and a filter that silently widens is a filter
that authorizes. Parsing happens before the store is opened, so a usage error
never boots a decision stack.
Filtering needs an object source for the type, exactly as enumeration itself
does. A model that declares none reports APERTURE_PROVIDER_UNREGISTERED rather
than printing nothing — an empty list would read as "no access". (Because the
predicate is applied per candidate, you only see that error once the principal is
allowed at least one object under the pattern.)
Restricting to what a reference names
--via asks the other direction. --field asks "which datasets contain
brand Y?"; --via asks "which brands does dataset X list?".
bin/aperture enumerate alice read 'account:acme/brand:*' --seed ./model.yaml \
--via account:acme/dataset:x.current_brands
It restricts the listing to the identities held in a declared reference field
on one holder object — a references: block in the seed document
(Declaring a reference) is what makes
the field dereferenceable.
The two are not interchangeable, and the asymmetry is the reason --via
exists. The dataset holds current_brands, so a predicate on dataset expresses
"which datasets contain brand Y?" — that is --field. A brand holds no field
naming its datasets, so no predicate on brand can express "which brands belong to
dataset X?" at all. --field is a filter; --via is a dereference.
The spelling is <holder-identity>.<field>, split on the last . — a . is
legal inside an identity component (dataset:2026.q1) while a reference field is
a single metadata key, so the final dot is the only unambiguous boundary. The
holder's type is deliberately not spelled: it is the identity's last segment
type, which the engine derives itself, so the two cannot disagree.
The rules the restriction obeys:
- Repeatable, and edges are ANDed. Two
--viaflags give "the brands in dataset x and in campaign spring". - It composes with
--field, and both apply before--limit. - It only subtracts. Restriction runs on candidates that still go through the
access decision, so
--viacan never surface an objectcheckwould deny. - Exactly one hop. The brands a dataset names are not themselves
dereferenced, however many references
branddeclares.
A holder you may not read prints nothing and exits 0. That is deliberate, not
a bug: "you may not see dataset X" and "dataset X lists nothing you may see" must
not be tellable apart, or --via becomes a way to probe for objects you were
never allowed to know about. The same goes for a holder in another account —
empty whether or not it exists — and for a principal who is not a member of
--account.
An absent holder is APERTURE_NOT_FOUND only when it is inside --account
and the principal is a member of it. That is the ergonomics a typo deserves,
confined to someone already inside the account.
A wiring fault stays loud rather than printing nothing: a field with no
references: declaration is APERTURE_PROVIDER_REFERENCE_INVALID, and a holder
type with no provider is APERTURE_PROVIDER_UNREGISTERED. A malformed --via —
no ., an empty holder, an empty field — is APERTURE_INVALID_INPUT naming the
offending text, rejected before the store is opened.
If a --via returns nothing you expected, check the server log first: a
referenced identity the provider no longer serves is skipped with a warning
naming it, and the commonest cause is an identity composed in the wrong shape
(brand:1 where the deployment yields account:acme/brand:1).
Full flags: enumerate.
search — find an object by the name a person types
aperture search [options] <principal> <action> <pattern> <query>
search ranks every object under <pattern> that <principal> may take
<action> on by how well its metadata matches <query>, best match first. It is
the name-to-id lookup a surface does when a question arrives in a person's own
words.
bin/aperture search alice read 'account:acme/brand:*' nike --seed ./catalogue.yaml
bin/aperture search alice read 'account:acme/brand:*' nike --in label --limit 5 --scores
Every result is one check would allow: candidates are decided before they
are scored, by the same walk enumerate uses, so the output is always a subset
of what enumerate returns for the same principal, action and pattern. A score
ranks; it authorizes nothing.
Matching is case- and punctuation-insensitive (Nike, Inc. matches nike inc)
and tolerates a typo or a transposition on tokens long enough for one to be
unambiguous. Only text is matched — a string field and the string elements of
a list field; match a number, bool or date with --field instead.
Aperture has no notion of a "label": a label is an ordinary metadata field whose
name your host chose. Every field holding text is searched by default; --in
names the fields to search, and --scores prints the score plus the field and
value that produced each match — which is what tells a hit on an alias apart
from a hit on a display name.
account:acme/brand:3 0.875 label=Nike, Inc.
account:acme/brand:1 0.800 label=Nike Air Max Collection
--field / --fields-json and --via mean exactly what they mean on
enumerate, and they compose with the query: the predicate and the reference
edge narrow the candidate set, the query ranks what is left. "The brand called
Nike in dataset X" is one command.
--min-score drops weak matches (default 0.4). Raising it narrows the
shortlist; it never widens what the principal may see.
--limit caps how many matches come back — the top of a finished ranking,
not a bound on the scan, so you get the best N rather than the first N found. The
scan itself runs to the deployment's --enumerate-limit ceiling; a scan that
fills that ceiling is logged, because a better match may lie past it.
Because it needs object metadata to match against, search errors with
APERTURE_PROVIDER_UNREGISTERED against a model that declares no object source —
an empty list would read as "you may see nothing" and hide the misconfiguration.
Full flags: search.
identifiers — a type's valid instance ids
aperture identifiers [options] <object_type>
identifiers lists every valid instance id of an object type, read from the
object source the model binds to that type — a providers: entry (a CSV file
today, a data source later) or inline objects: metadata. --exclude drops ids
from the result — this is how an exclusive "all except these" allowance expands
into a positive allow-list. Because it needs a source, identifiers errors with
APERTURE_PROVIDER_UNREGISTERED against a model that declares none, so run it
against a seed that binds the type:
bin/aperture identifiers document --seed ./model-with-providers.yaml
bin/aperture identifiers document --seed ./model-with-providers.yaml --exclude secret
Full flags: identifiers.
Related
- Global options —
--seed/--store/--account, and why the principal is positional here. - First decision (CLI) — the same commands walked through against the example model.
- Mutations — change the grants these decisions read.
- Performance & the NFR — what the deployment's enumeration ceiling costs, measured.
- Command-Line Reference — the generated flag tables.
Mutations
Audience: administrators changing the Aperture model from a shell.
The mutation commands read and change the model: generic entity CRUD (put,
get, list, delete), delegation (bestow, revoke), and impersonation
(impersonate). Each builds the fully-wired facade — the same
storage → engine → gate → delegation → impersonation → service graph the
serve command mounts — so a CLI mutation is gated exactly as the HTTP/Twirp
surface gates it. There is no CLI-only write path.
Every write command needs an acting principal: the authenticated caller
performing the change, taken from --principal (or APERTURE_PRINCIPAL) — not a
positional argument. The reads (get, list) need no actor. See
Global options for the
subject-vs-actor distinction, and set it once per shell:
export APERTURE_PRINCIPAL=root
Entity bodies are the canonical JSON encoding of the corresponding model.*
struct — the same shape the Twirp entity_json field carries. Supply one with
--json, --file, or on stdin (in that order).
Entity CRUD: put, get, list, delete
put <kind> creates or updates one entity; the admin tier it requires depends
on the kind. It prints put <kind> ok.
bin/aperture put grant --account acme --json '{
"id": "g-analyst-read",
"accountId": "acme",
"subject": {"kind": "role", "id": "analyst"},
"permissionId": "perm-doc-read",
"object": "account:acme/project:atlas/**",
"effect": "allow"
}'
get <kind> <id> reads one entity as pretty JSON (no actor, no tier):
bin/aperture get grant g-eng-read-atlas
{
"ID": "g-eng-read-atlas",
"AccountID": "acme",
"Subject": { "Kind": "group", "ID": "engineering" },
"PermissionID": "perm-doc-read",
"Object": "account:acme/project:atlas/**",
"Effect": "allow"
}
list <kind> lists entities of a kind as JSON. Listing grants requires
--account (grants are per-account); the other kinds do not:
bin/aperture list principals
bin/aperture list grants --account acme
delete <kind> <id> removes one entity, gated by the kind's tier; it prints
delete <kind> <id> ok. Memberships are keyed by (principal, account) rather
than a single id, so delete membership takes --principal-id and
--account-id instead:
bin/aperture delete grant g-analyst-read --account acme
bin/aperture delete membership --account acme \
--principal-id bob --account-id acme
Full flags: put,
get,
list,
delete.
Delegation: bestow and revoke
bestow lets a principal delegate a grant it already holds to another
principal. Unlike put grant, it is not gated by an admin tier — it enforces the
delegation subset rule: you can only bestow authority you hold, over a
delegatable permission. The delegating principal is named by --delegator (env
APERTURE_PRINCIPAL); the grant body is a normal grant JSON. It prints
bestow <grant-id> ok.
bin/aperture bestow --delegator alice --json '{
"id": "g-bob-read-42",
"accountId": "acme",
"subject": {"kind": "principal", "id": "bob"},
"permissionId": "perm-doc-read",
"object": "account:acme/project:atlas/document:42",
"effect": "allow"
}'
revoke is the inverse: it removes a grant the delegator previously bestowed,
by id. It prints revoke <grant-id> ok.
bin/aperture revoke --delegator alice --grant g-bob-read-42
Impersonation: impersonate
impersonate starts a time-boxed session in which an --operator acts as a
--target within --account, and prints the session as JSON. --mode is
augment (add the target's authority to the operator's — the default) or
become (resolve purely as the target). It is guarded: an operator with no
right covering the target is denied with APERTURE_IMPERSONATION_DENIED.
bin/aperture impersonate --operator root --target alice --account acme --mode augment
Full flags: impersonate.
Related
- Global options —
--principal/--account/--seed/--store. - Decisions — check the effect of a grant you just changed.
- Provisioning — apply many grants at once via templates or
bulk. - Portability — move a whole model between stores.
- Command-Line Reference — the generated flag tables.
Provisioning
Audience: administrators granting access at scale from a shell.
The provisioning commands turn repeated grant-making into one transactional
call: template manages and applies parameterized grant bundles, and bulk
applies or removes many grants atomically. Both build the same fully-wired,
tier-gated facade the mutation commands use, and both need an
acting principal via --principal (or APERTURE_PRINCIPAL) plus, in most cases,
--account. Set the principal once:
export APERTURE_PRINCIPAL=root
template — parameterized grant bundles
aperture template <put|get|list|delete|apply>
A template is a named, versioned bundle of grants with named parameters. The subcommands split into CRUD and apply:
| Subcommand | Tier | What it does |
|---|---|---|
template put | system-admin | Create or update a template (from --json / --file / stdin). Prints put template <name> v<version> ok. |
template get <name> | none | Read a template as JSON (latest unless --version). |
template list | none | List every template version as JSON. |
template delete <name> | system-admin | Delete one --version, or all versions when --version is 0. |
template apply | account-admin | Instantiate a template's grants transactionally into --account. |
template apply binds --param name=value (repeatable) into the template and
writes the resulting grants in one transaction; --id-prefix prefixes the
generated grant ids, and --version 0 (the default) applies the latest. It
prints the applied grants as JSON.
# Define a template (system-admin tier):
bin/aperture template put --account acme --file ./project-onboarding.json
# Apply it into an account, binding parameters (account-admin tier):
bin/aperture template apply --account acme \
--name project-onboarding \
--param project=atlas \
--param team=engineering \
--id-prefix onboard-
Full flags: template.
bulk — many grants in one transaction
aperture bulk <grant|revoke>
bulk grant applies a JSON array of grant bodies atomically — either all
land or none do — from --json, --file, or stdin. It prints bulk grant <n> ok.
bin/aperture bulk grant --account acme --json '[
{"id":"g-a","accountId":"acme","subject":{"kind":"role","id":"analyst"},"permissionId":"perm-doc-read","object":"account:acme/project:atlas/**","effect":"allow"},
{"id":"g-b","accountId":"acme","subject":{"kind":"role","id":"analyst"},"permissionId":"perm-doc-write","object":"account:acme/project:atlas/document:42","effect":"allow"}
]'
bulk revoke deletes many grants atomically by id. Ids come from repeated
--grant flags, positional arguments, or both; it prints bulk revoke <n> ok.
bin/aperture bulk revoke --account acme --grant g-a --grant g-b
# or positionally:
bin/aperture bulk revoke --account acme g-a g-b
Both bulk subcommands are account-admin tier.
Full flags: bulk.
Related
- Global options —
--principal/--account/--seed/--store. - Mutations — single-entity
put/deleteand delegation. - Portability — export/import a whole model at once.
- Command-Line Reference — the generated flag tables.
Portability
Audience: administrators moving or backing up a whole Aperture model.
export and import are the declarative-state commands: export serializes the
entire model to a single JSON/YAML state file, and import applies such a file
back as an idempotent, transactional upsert. Both are system-admin tier and
drive exactly the path the Twirp Export / Import RPCs drive, so they need an
acting principal via --principal (or APERTURE_PRINCIPAL) and --account for
authority resolution:
export APERTURE_PRINCIPAL=root
export — serialize the whole model
aperture export [options]
export writes the full model to stdout by default, or to --out <path>. The
format is JSON unless you pass --format yaml (or --out a .yaml/.yml
path). Writing to a file prints a one-line summary; writing to stdout emits the
raw document.
# To stdout as JSON:
bin/aperture export --account acme --principal root > model.json
# To a YAML file (format inferred from the extension):
bin/aperture export --account acme --principal root --out model.yaml
exported <model summary> -> model.yaml
Full flags: export.
import — apply a state file
aperture import [options]
import reads a state file from --file <path> or, when no file is given, from
stdin (treated as JSON). It applies the document as an idempotent upsert in
one transaction — re-importing the same file is a no-op — and prints a one-line
summary. The file format is inferred from the --file extension.
# From a file:
bin/aperture import --account acme --principal root --file model.yaml
# From stdin (JSON):
bin/aperture export --account acme --principal root \
| bin/aperture import --account acme --principal root
imported <model summary>
Round-tripping through export | import — or between two --store DSNs — is the
supported way to snapshot, back up, or migrate a model. Because both ends run as
a system-admin actor scoped to --account, no cross-account data crosses the
boundary implicitly.
Full flags: import.
Related
- Global options —
--principal/--account/--storefor the source/target store. - Provisioning — apply incremental grants rather than a whole model.
- Mutations — single-entity edits.
- Command-Line Reference — the generated flag tables.
Attributes
Audience: operators who need to know which host directories a deployment
reads principal.* and account.* out of — and how fast a revocation lands.
An attribute slot is a host directory: the user table, the service-account
registry, the tenant catalogue. There are exactly three — user, machine, and
account — and each caches the bags it has fetched, with its own ttl: and
max_size:. See Providers for
the seam itself and Seed &
portability for the YAML that
wires it.
aperture attributes <slots|query|invalidate>
All three read through the same wiring builder every other command uses, so
the slots a listing reports are the slots a decision resolves through: the CLI
cannot describe a wiring it does not itself run. All three therefore take
--seed (and --store) exactly as check does.
| Subcommand | Tier | What it does |
|---|---|---|
attributes slots | none | one row per layer of each slot: where that layer is wired from, its own ttl and max-size, and how many bags this process has cached for the slot |
attributes query <slot> | system-admin | a page of that slot's directory as [{id, attributes}], narrowed by attribute predicates |
attributes invalidate <slot> | system-admin | drop cached bags so the next decision re-reads them |
The cache window is a security property
Object metadata going stale for a TTL is usually tolerable — a document's
category is a fact about a thing. An attribute bag is the asker's standing:
the clearance, the department, the plan. Until a cached bag expires, every
decision about that subject keeps evaluating against access the host may have
already taken away, so a revocation that takes effect ttl: later is a
revocation that has not happened yet.
That is the frame for both of the interesting columns below and for the whole of
invalidate: pick a slot's ttl: for how fast its revocations must land, read
back what the deployment is actually running, and close the window explicitly
when you cannot wait.
slots — what is wired, where from, and how stale it may be
bin/aperture attributes slots --seed ./seed.yaml --store 'postgres://…/acme'
slot layer source ttl max-size cached
user shared sql (shared wiring) 1m0s 10000 15
user local inline (--seed file) never 10000 -
machine shared csv (--seed file) 30s 10000 0
account - (unwired) - - -
One row per LAYER, not per slot. A slot has two registration layers and each one caches on its own declaration, so a single row could only report one of two revocation windows — and it reported the winner's, which is the wrong number for every key only the other layer serves. The layers are printed in precedence order, highest first.
layerissharedorlocal. Anattribute_providers:entry fills the shared layer and anattributes:block fills the local one; a fetch reads their merge and the shared layer wins every key both serve, so nothing is discarded and the inline bags still contribute the keys the external source does not carry. A slot with no source at all reads-.sourcenames the kind and the place:sql/csvfor anattribute_providers:entry,inlinefor theattributes:block, then(shared wiring)for a row in the deployment's database — put there byaperture wiring pushand read by every instance — or(--seed file)for this instance's own document.(host)is a provider the binary embedding Aperture registered in Go, which no document describes.(unwired)is a slot this deployment declares nothing for: not an error, and not an empty directory — every decision for it resolves the floor bag and proceeds.ttlis the revocation window, and there is one per layer.nevermeans a fetched bag is dropped only by eviction or by an explicitinvalidate— correct for a fixed inline block, dangerous for a live directory. Two layers are two caches and never an average: a shared directory on a one-minute window beside an inline block that never expires leaves every key only the inline block serves cached indefinitely, and a listing that reported1mfor the slot would tell an operator who had just revoked something that the window was closed.cachedcounts this process, for the whole slot. The counters are kept per slot and summed across its layers — a subject both layers serve is held twice, because it is cached twice, and twottls will expire it — so the number appears once, on the slot's first row, and reads-on the second layer's. A one-shot invocation starts cold and reads0; it is the number that matters in a long-runningserve.
slots needs no actor. It discloses nothing the caller did not already
supply: it reads the seed file named on the command line, the shared wiring rows in
the --store named beside it, and the cache configuration this process built from the
two. It contacts no provider, names no key, and prints no bag. Requiring system-admin
authority to read back a file you just passed in would only mean nobody could diagnose
"is the user slot even wired?" without already holding the authority the diagnosis
exists to explain — and the wiring half needs no gate for the reason
aperture wiring show needs none: the
--store credential already grants full write access to those rows.
When a deployment declares one slot in both attribute_providers: and
attributes: — in one file, or with the entry pushed and the block local — every
command that builds the stack prints a warning naming the affected slots. Nothing is
discarded: the attribute_providers: entry is that slot's shared layer, the
inline bags layer under it, and the shared layer wins every key both serve. The
warning exists to say which layer answers a contested key — the one thing no
verdict, trace or note says. Only slot names are named, never keys. See Precedence:
two layers, and the shared layer
wins.
query — read a directory (system-admin)
bin/aperture attributes query user \
--principal alice --account acme \
--field department=eng --fields-json '{"clearance":3}' --limit 100
Prints a JSON array of {id, attributes}.
Unfiltered, this returns the head of the host's user table — keys and bags
together — so it is a system-tier read: --principal must hold system-admin
authority in --account. A refusal returns nothing: no partial page, no
count, and no way to tell an empty slot from a full one or from an unwired one.
That is deliberate — the authority check runs before the slot name is even
parsed, so a refused caller cannot use the error to probe which directories a
deployment wires. If a refusal is unexpected, ask
aperture explain about your own authority.
--field and --fields-json narrow by attribute, on exactly the predicate
aperture enumerate applies to object metadata: predicates are
ANDed, a field the bag does not carry never matches, a list-valued field matches
by membership, and everything else matches by typed equality, so the string
"5" never matches the number 5. --field always sends a string; reach for
--fields-json when a number, bool, or list is genuinely meant. Given both,
--fields-json is merged first and --field entries override it by key.
A slot whose attribute_providers: entry declares no get_all: is
fetch-only by design — it answers the decision path without exposing the
whole table to an enumeration — and query reports that provider's coded refusal
rather than an empty page.
invalidate — close the window now (system-admin)
bin/aperture attributes invalidate user --id alice --principal root --account acme
bin/aperture attributes invalidate user --principal root --account acme
bin/aperture attributes invalidate --all --principal root --account acme
The three forms — one subject, one slot, everything — are mutually exclusive,
and a conflict is refused rather than resolved by precedence: "--all plus a
slot" has two plausible readings, and guessing the broader one would clear caches
the operator did not ask to clear.
"Nothing was cached" is reported, not silently succeeded
(no cached user bag for "alice"): an operator invalidating a subject they
believe is cached wants to know their key did not match. Note that --id takes
the bare subject id — alice, never user:alice.
Every form clears both layers of every slot it names. A slot fed by an external source and an inline block caches each independently, and dropping one would leave the revoked value still being read out of the other — a window reported shut with half of it open.
It is gated for the same reason query is, even though it writes nothing and
discloses no bag: the result says whether this process had that key cached,
which is a fact about who has recently been decided about, and clearing a large
slot costs the next wave of decisions a provider round-trip each.
invalidate --all names no slot, so it cannot be used to probe which exist.
Invalidation is process-local. It clears the caches of the process that runs
it. That makes it exact for a host embedding Aperture, and self-contained for a
one-shot CLI invocation (which starts cold and exits cold) — but it cannot
reach a running aperture serve. The controls that reach that
process's cache are the slot's ttl: and a restart.
Full flags: attributes.
Related
- Global options —
--principal/--account/--seed/--store. - Providers — the attribute seam, its leniency contract, and why enumerating a slot can never be scope resolution.
- Seed & portability — the
attributes:andattribute_providers:blocks these commands report on. - Rules engine — what a rule reads when a slot answers nothing.
- Command-Line Reference — the generated flag tables.
wiring
Audience: operators who deploy Aperture, and anyone answering "is what is deployed what is in the repository?".
aperture wiring is the operator's window onto shared wiring: the part of a seed
document that belongs to the deployment rather than to one instance. Push it once
and every instance sharing that database reads the same wiring — including an instance
that has no seed file at all.
Four subcommands, and the tree is complete:
| Command | Question it answers |
|---|---|
push | make the deployment's wiring this document's four shared sections |
show | what is deployed, in words |
pull | what is deployed, as a file a push accepts unchanged |
diff | does the deployment match the repository |
All four take --store and no acting principal. The store credential is the
authority, exactly as it is for import, and the three read
commands restate wiring that credential already grants full write access to. --store
has no default on any of them: an in-memory store is private to the process that
opened it, so there is nothing shared about its wiring to write or to read.
Throughout, the deployment is the house example: one account, acme, whose objects
are spelled account:acme/project:atlas/document:42.
Four sections are shared and two are local
A seed document's ten model-state sections are applied by --seed (or
import). Its six wiring sections say where a decision reads
object metadata and subject attribute bags from, and they split four/two:
| Section | Home |
|---|---|
connections: | shared — the manifest of connection names, and nothing else |
providers: (with its references:) | shared |
field_types: | shared |
attribute_providers: | shared |
objects: | local to the instance whose file lists it |
attributes: | local to the instance whose file lists it |
The line is a pointer to data versus the data itself. A providers: or
attribute_providers: entry says where to read metadata or an attribute bag from; a
field_types: entry says how to read a field; a connections: name says which
pool an entry cites. All four describe the deployment and all four are safe to copy to
a second host. An objects: entry carries the metadata and an attributes: entry
carries the bag — and Aperture's own database is never the source of truth for a
host's domain data. Those two are not waiting for a later release; there is nowhere
for them to go.
Wiring is also not model state, and the two are pushed by different commands.
aperture export emits the model and no wiring; aperture wiring pull emits the
wiring and no model. Wiring for a model that is not there is refused rather than
stored — see push.
What is never stored
No DSN, no credential, not even the name of the environment variable holding one, and no filesystem path. This is a property of the schema — there is no column for any of it — rather than of a validator, and it is the reason the surface is safe at all:
- A row is copied to a second instance that resolves its own credentials, so a secret column would put a credential in every backup of this database.
- A path column would be a guess about another host's disk. A relative
path:is resolved against the seed file's directory, which database-sourced wiring has none of.
kind: csv is therefore refused by push (APERTURE_WIRING_KIND_UNSHAREABLE), and
stays perfectly legal in a local seed file — the instance that reads the path is
the instance the path belongs to.
A shared connection carries its name, and each instance supplies its own
route for that name: seed.WithConnectionOpener in a Go host, a connections:
entry under the same name in this instance's own seed file, or the conventional
APERTURE_CONNECTION_<NAME>_DSN. That is why a pulled document comes back with an
empty dsn_env: for every connection: it is re-pushable but not bootable until
the routes are filled in. The three routes are set out in full under
It means the same thing for wiring.
push — deploy a document's shared wiring
aperture wiring push --store <dsn> --seed <file>
Reads providers:, field_types:, connections: and attribute_providers: out of
--seed, validates every entry, and replaces the store's wiring with them in one
transaction. The document's other sections are not read: no model state is applied,
and the two local wiring sections are untouched.
bin/aperture wiring push --store 'postgres://…/acme' --seed model.yaml
The push is all or nothing. Every rule is checked before anything is written, so a refusal leaves the deployed wiring exactly as it was. Wiring is only meaningful whole — an entry citing a connection the manifest does not list is not half-valid wiring, it is broken wiring — and an instance booting against a half-written set would build a registry missing exactly the entries whose write failed, while reporting nothing.
It replaces rather than merges. An entry dropped from the document is dropped from the store. Push the whole wiring every time. There is deliberately no per-row edit.
A push is refused when:
| Refusal | Because |
|---|---|
APERTURE_WIRING_NO_MODEL_STATE | the store holds no model state at all, so there is nothing for the wiring to be wiring for. A typo'd DSN opens — and Setup creates — a perfectly valid, perfectly empty database, and without this the wiring lands there and the instance that serves decisions never sees it. |
APERTURE_WIRING_KIND_UNSHAREABLE | an entry selects kind: csv, whose only data source is a machine-local path. |
APERTURE_WIRING_OBJECT_TYPE_UNKNOWN | a provider entry serves an object_type the store has no row for. The refusal names the type. |
APERTURE_WIRING_CONNECTION_UNDECLARED | an entry cites a connection: the pushed manifest does not declare. The refusal names it and lists the ones that were. |
APERTURE_SQL_PROVIDER_DSN_LITERAL | anything carries a literal dsn:. Only dsn_env:, a variable name, is ever accepted — and shared wiring stores neither. |
APERTURE_WIRING_EMPTY_PUSH | the document declares no wiring at all and --allow-empty was not given. See below: this is the one refusal about what the push would mean. |
An all-empty document is refused
Because a push replaces, a document with no wiring in it is not "nothing to do" — it
retires every entry the deployment is running. All five tables are emptied and
every instance goes back to building its wiring from its own --seed file; an
instance with no --seed file, which is the shape this whole feature exists to
enable, then builds none. It still starts, with an empty object registry and an
empty attribute registry. Every attribute slot is unregistered, an unregistered slot
answers with an empty bag, and a rule that excluded on an attribute it can no longer
see stops excluding — so the grant widens, with nothing in a verdict, a trace or a
note to say so. Under --wiring-poll it
lands without a restart.
The two documents that produce an empty set are indistinguishable from the store's
side and both are usually mistakes: the wrong --seed path, and a file whose
section key is mistyped. provider: for providers: is not an error — the seed
reader does not refuse unknown keys, so the section is simply not there.
Retiring a deployment's wiring is a legitimate act, so --allow-empty says it
deliberately; the summary then spells out what was retired, because five zeroes read
as a summary rather than as a retirement.
A key that names no section is reported
The refusal above only fires when the document has no wiring at all. A partial retirement — one mistyped key in a document that still declares other sections, so the set is not empty and the push legitimately proceeds — is invisible to it, and from the store's side indistinguishable from deliberately dropping an entry.
So push warns about every top-level key that names no section, before it writes:
warning: wiring.yaml carries 1 top-level key(s) that name no section: provider
Nothing was read from them. If one is a mistyped wiring section, the push below
retires that section for every instance of the deployment.
It is a warning and never a refusal, because a document may legitimately carry keys Aperture does not know — another tool's section, or a key from a newer Aperture than the binary reading it — and refusing those would break files that work today. Only the key names are printed: a value under an unknown key could be anything, including a credential somebody put in the wrong place.
diff is the other half, and
the one that catches a section dropped without a typo.
A push is audited: one WiringPush mutation record against
model.AccountWildcard, carrying the per-section counts and the deployed connection
names, object types and attribute slots. All of that is deployment-wide wiring
vocabulary; none of it is account data.
Full flags: wiring push.
show — read what is deployed
aperture wiring show --store <dsn>
Prints the five shared-wiring tables, one table per section, so "what is this
deployment actually wired to?" is answerable without a database client. It takes no
--seed: a listing that merged the document on the command line would answer "what
would a push deploy?" while looking like it answered this question.
bin/aperture wiring show --store 'postgres://…/acme'
An empty store is an answer, not an error. A store nothing has been pushed to says
so plainly, and every instance booting against it builds its wiring from its own
--seed file instead. It is also what a mistyped --store looks like, because a DSN
naming a database that does not exist yet is one Setup creates — so check the DSN
before concluding a push was lost.
Two columns are spelled as words rather than left blank. ttl and max-size read
(default) when the entry sets neither, because the registry's own default applies and
0 would read as "caches nothing". The declared key set distinguishes three states:
(not declared) is a slot that opted out of key enforcement entirely,
(declared empty) is a slot that opted in and permits no keys at all, and a list
is the keys the slot guarantees.
Full flags: wiring show.
pull — snapshot what is deployed to a file
aperture wiring pull --store <dsn> --out <path> [--format json|yaml] [--force]
Reads the deployed wiring in one atomic snapshot and writes it as the same four
sections a push reads. The file is a seed document push accepts unchanged, so
push → pull → push deploys identical wiring, and two pulls of an unchanged
deployment are byte-identical.
bin/aperture wiring pull --store 'postgres://…/acme' --out deployed.yaml
diff -u model.yaml deployed.yaml
show is the better command for reading; this one produces a file for a tool. Three
behaviours are worth knowing before you script it:
- The file is re-pushable but not bootable. Every connection comes back with an
empty
dsn_env:, because shared wiring holds a name and nothing else. Fill the routes in from your own deployment's environment before booting an instance from the file; one built from it as written refuses at registry build and names the unset variable. - An existing
--outfile is refused unless--forceis given (APERTURE_WIRING_OUTPUT_EXISTS). The likeliest thing at that path is the version-controlled document the pull is meant to be compared with. The path is checked before the store is opened, so the refusal reads nothing. - A store with no wiring deployed is refused
(
APERTURE_WIRING_NOTHING_DEPLOYED) — the one placepulldisagrees withshow. A pull's file exists to be pushed back, and an empty one pushed back replaces the deployment's wiring with nothing.
Full flags: wiring pull.
diff — compare the deployment against the repository
aperture wiring diff --store <dsn> --seed <file>
Names, per section and per entry, what is only deployed, what is only local, and what
differs. The section column is the document's own section key, so a reported entry is
one you can go and find. Nothing is written, to the store or to a file.
bin/aperture wiring diff --store 'postgres://…/acme' --seed model.yaml
It is meant for a pipeline, and the exit codes are the interface:
| Exit | Meaning |
|---|---|
0 | the deployment matches the repository |
2 | it does not |
1 | I could not tell — an unreadable document, a --store that names nothing, a schema this build cannot read |
Drift is the answer to the question asked, not a failure to answer it, so it
carries no APERTURE_* code — there is no remedy for a report, and which side is
wrong is your call. It follows check's precedent: a clean DENY
prints its verdict and exits non-zero, because a verdict is not an error either.
The code is 2 and not 1, and that is the useful half. Every coded refusal this binary makes exits 1, so a gate that failed closed on 1 and 2 alike would report drift for a typo in its own DSN.
Two more properties keep a gate green when it should be:
- Formatting is never drift. Both sides are compared as wiring, in the one
canonical order a read returns, so re-ordering the document's entries, re-ordering a
fields:orreferences:map, or re-indenting the file changes nothing. The push timestamps are excluded too — a stamp is a fact about the last push, not about the wiring. - A
kind: csventry is reported as local-only by design, not as drift and not as an error. No push can make the deployment match it, so counting it as drift would leave a permanently red gate on every deployment that legitimately keeps a local csv provider — and a gate that cannot go green is a gate somebody switches off.
Make the deployment agree with the document by pushing it.
Full flags: wiring diff.
Booting against pushed wiring
An instance reads the shared wiring at startup, and what it does with what it finds is documented once, under Global options:
- Empty tables mean "use the local file" — an answer, not a failure, and the state of every deployment that has never pushed.
- With both, the database wins and the file may only ADD
— a collision is refused in both directions with
APERTURE_WIRING_LOCAL_COLLISION, because either precedence is silent and either one changes what a decision reads. - An instance will not start on wiring it cannot construct — refusing to boot beats degrading, because a missing provider does not error on a decision and a missing attribute bag widens an exclusive grant.
- The wiring is read once, unless you ask for more
—
serve --wiring-pollis opt-in and off unless configured; see Noticing a push without a restart.
Related
- Two instances, one store — the four steps of standing up a second instance against a database that already has one.
- Seed & portability — the six sections, and the three read-backs that answer three different questions.
- Storage — the five
apt_wiring_*tables and what they may never carry. - Global options —
--seed/--store, and what an omitted--seedmeans for wiring. - Portability —
export/import, the model half of the same story. - Command-Line Reference — the generated per-command flag tables.
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.
mcp
Audience: integrators wiring Aperture into an MCP client (an AI assistant or agent runtime).
aperture mcp [options]
mcp serves Aperture's read-only decision surface over stdio — the transport
an MCP client uses when it spawns Aperture as a subprocess. It exposes the
decision API (check / enumerate / explain, single and bulk), a read-only
what-if simulator, and model inspection as MCP tools. No tool mutates. The
command wires the facade with storage for inspection and what-if reads, but
deliberately not the gate, delegation, or impersonation mutators — the surface
can never write.
Because it speaks the MCP protocol over stdio, you normally don't run mcp
interactively; an MCP client launches it. A minimal client configuration points
at the binary and the model to serve:
{
"mcpServers": {
"aperture": {
"command": "bin/aperture",
"args": ["mcp", "--store", "./aperture.db"]
}
}
}
On start it prints one line to stderr (stdout is reserved for the protocol):
aperture mcp: serving read-only MCP surface over stdio
What the flags control
--seed/--store— the model the surface reads, exactly as elsewhere (see Global options). With neither, it serves the embedded example model over an in-memory store.
There are no other flags: the surface is read-only by construction, so it needs no acting principal, account, or auth adapter.
Full flags: mcp.
Related
- Global options —
--seed/--store. - serve — the read/write HTTP + Twirp surface.
- Decisions — the same check/enumerate/explain questions from a shell.
- Command-Line Reference — the generated flag table.
Library overview
Aperture is library-first: the public Go packages at the module root are the product, and every other surface — the CLI, the RPC/HTTP API, the MCP server, and the admin UI — is a thin translator over them. This part of the book is the reference for embedding Aperture directly in a Go host program.
If you have not run the Library quickstart
yet, start there: it wires a store, an engine, and the facade end to end and asks
one Check. This section then documents the full decision API those pieces
expose.
Two layers, one decision
A host program drives Aperture through two collaborating packages:
| Package | Type | Role |
|---|---|---|
engine | *engine.Engine | The Policy Decision Point (PDP). It resolves a raw authorization question against storage with deny-overrides plus a specificity tiebreak. It is stateless beyond its storage handle and safe for concurrent use. |
service | *service.Service | The decision facade every surface calls instead of touching the engine directly. It adds one shared fail-closed rendering policy, decision auditing, the what-if Simulate path, and — when wired with options — the mutation surface. |
The engine answers the pure question; the facade is where the surface-facing policy lives (how an operational error becomes a rendered deny, when a decision is audited, how a what-if overlay is layered). A host that wants the raw PDP can call the engine; a host that is building its own surface should call the facade, so it inherits the same fail-closed contract every built-in surface has.
import (
"github.com/frankbardon/aperture/engine"
"github.com/frankbardon/aperture/service"
"github.com/frankbardon/aperture/storage/memory"
)
store := memory.New()
_ = store.Setup(ctx)
eng := engine.New(store) // the PDP
svc := service.New(eng) // the facade every surface calls
engine.New(store, opts...) and service.New(eng, opts...) both take functional
options; see Constructing the engine
and The service facade for the options each exposes.
The decision API
Both layers expose the same three operations, in single and bulk-batched forms, with an impersonation-aware variant of each on the engine:
| Operation | Question it answers | Single | Bulk | Impersonated |
|---|---|---|---|---|
| Check | May this principal take this action on this one object? | Check | CheckBatch | CheckAs |
| Enumerate | Which objects under a pattern may this principal act on? | Enumerate | EnumerateBatch | EnumerateAs |
| Explain | Why was this decision reached — which grants and at what specificity? | Explain | ExplainBatch | ExplainAs |
The pages that follow cover each cluster:
- Decision API — the single
Check/Enumerate/Explainoperations: real signatures, input and result shapes, and when to reach for each. - Batch operations —
CheckBatch/EnumerateBatch/ExplainBatchand the genericBatchResult[T]type that keeps one bad query from failing a whole batch. - Impersonation —
CheckAs/EnumerateAs/ExplainAsand theImpersonationContextdecorator that steers which subject set a decision resolves over. - The service facade — how surfaces call the facade, its
fail-closed rendering, and the read-only
Simulate/SimulateExplainwhat-if path with a worked example.
Errors are always coded
Every failure Aperture returns across a package boundary is an APERTURE_*
coded error, never a bare string. Recover the code with errors.CodeOf rather
than matching on the message:
import aerr "github.com/frankbardon/aperture/errors"
if err != nil {
switch aerr.CodeOf(err) {
case aerr.APERTURE_INVALID_INPUT:
// the query was malformed — a caller bug
case aerr.APERTURE_NOT_FOUND:
// an unknown principal, permission, or entity
default:
// an operational failure (storage, an unresolvable strategy, ...)
}
}
The engine and the facade differ in how they treat these errors — the engine returns them, while the facade folds operational failures into a fail-closed deny. The distinction is spelled out per operation on the pages below and in The service facade. The full catalog lives in the Error Codes reference.
The vocabulary these operations are built from — principal, action, object, pattern, specificity, grant, account — is defined in the Concepts primer and expanded in the Concepts section of this book.
Decision API
The engine package is Aperture's Policy Decision Point. It exposes three single
operations — Check, Enumerate, and Explain — on *engine.Engine. This page
gives their real signatures, input and result shapes, and the rule for choosing
between them. Their bulk and impersonation-aware forms live in
Batch operations and Impersonation.
All three are methods on an engine you construct once and reuse. The engine is
stateless beyond its storage handle and safe for concurrent use to whatever
degree the underlying Storage is.
Constructing the engine
func New(store model.Storage, opts ...Option) *Engine
With no options the engine uses the literal identity-pattern coverer — a grant's object pattern is matched directly against the requested object. The options extend that behaviour:
| Option | Effect |
|---|---|
WithScopeResolution(registry *scope.Registry, deps ...ScopeDeps) | Consult each grant's pluggable scope resolver (selected by its permission's scope-strategy) for object membership, instead of only literal pattern matching. A nil registry uses scope.DefaultRegistry(). |
WithEnumerateLimit(n int) | Set the ceiling an enumeration is bounded by: the value a request with a non-positive Limit receives, and the value a larger Limit is clamped down to. It is also stamped into the ScopeDeps the engine wires, so the member gather and the result cap are one number. A non-positive n is normalised to DefaultEnumerateLimit, never stored — a silently zero bound would make every enumeration read as "no access". |
WithMembershipEnforcement() | Require the request's principal to be a member of the active account before any grant is consulted. A non-member is denied at the door (a fail-closed default-deny), rather than erroring. Off by default. |
WithMetadata(f MetadataFetcher) | Supply the object-metadata source Enumerate's Fields filter reads through — normally the same *provider.Registry wired as the scope lister. Consulted only by a request that carries Fields. |
WithReferences(r ReferenceSource) | Supply the declared-reference source Enumerate's reference edges are dereferenced through — normally the same *provider.Registry again. Consulted only by a request that carries References; an engine wired without it fails loudly for one that does, never with an empty result. |
WithLogger(l *slog.Logger) | The sink for non-fatal operational findings — a skipped dangling reference, and an enumeration that came back on its bound. Both are invisible to an operator otherwise, since neither changes the result's shape. Nil, or unset, means slog.Default(). It is not a decision log: nothing on the Check hot path logs. |
WithClock(now func() time.Time) | Override the engine clock. It governs impersonation time-box expiry only; the non-impersonated path never reads it. Production uses time.Now. |
Two further seams return a shallow copy of the engine rather than mutating it,
for the read-only what-if paths: (*Engine).WithStore(store) re-points the copy
at a different (e.g. overlay) store, and (*Engine).WithRuleEvaluator(re)
redirects rule-backed scope strategies at a different rule evaluator. Both leave
the original engine untouched, so a live engine and a transient what-if engine
never interfere. The facade's Simulate
path is built on exactly these.
Check
func (e *Engine) Check(ctx context.Context, req Request) (Decision, error)
Check resolves a single authorization decision: may this principal take this
action on this one object, in this account?
Request
Request is a value type; every field is mandatory.
type Request struct {
Account string // active account the decision is scoped to
Principal string // id of the principal requesting access
Action string // the verb being attempted, e.g. "read"
Object string // canonical object-identity string
}
Principal is a principal id (the key storage and the subject set are keyed
on), not the principal's identity string. Object is a canonical object-identity
string such as account:acme/project:atlas/document:42. Grants stamped to any
account other than Account are never consulted (the sole exception is a grant
stamped to the account wildcard *, which spans all tenancies).
Decision
type Decision struct {
Allow bool // the verdict: true permits, false denies
Reason string // human-readable explanation naming the deciding grant(s)
DecidingGrantIDs []string // ids of the grant(s) that produced the verdict, sorted; empty on a default-deny
Impersonation *ImpersonationContext // non-nil only under an active impersonation session (see Impersonation)
}
Reason names the deciding grant(s), their specificity, and how many grants were
considered. DecidingGrantIDs is sorted for determinism and is empty on a
default-deny. Impersonation is nil on the ordinary path.
Error contract
Check never returns an allow-on-error. Any operational failure — a
malformed request, an unknown principal, a storage fault — is returned as an
APERTURE_* coded error and the caller treats it as a non-decision. A
well-formed request that simply matches no grant is a clean default-deny
(Allow: false, no error). Default-deny is the floor: with no candidate grant
the answer is DENY.
dec, err := eng.Check(ctx, engine.Request{
Account: "acme",
Principal: "alice",
Action: "read",
Object: "account:acme/project:atlas/document:42",
})
if err != nil {
// operational failure — not a decision
return err
}
fmt.Println(dec.Allow, dec.Reason)
Building your own surface? Prefer the facade's
Service.Check, which folds operational errors into a fail-closed deny so a decision point can never fail open. The rawengine.Checkhere returns those errors for the facade to render.
Enumerate
func (e *Engine) Enumerate(ctx context.Context, req EnumerateRequest) ([]string, error)
Enumerate is the inverse of Check: it returns the object ids under a pattern
that the principal may take the action on, in the active account. Every id it
returns is one Check would allow — a denied object is never returned — so
the two operations agree by construction.
EnumerateRequest
type EnumerateRequest struct {
Account string // active account the enumeration is scoped to
Principal string // id of the principal whose access is enumerated
Action string // the verb being enumerated, e.g. "read"
Pattern string // identity pattern bounding the search
Fields map[string]any // optional object-metadata predicates; nil/empty filters nothing
References []ReferenceEdge // optional reference edges; nil/empty restricts nothing
Limit int // caps the number of returned ids; <= 0 means the configured bound
}
Pattern both bounds the candidate set and is intersected with each grant's own
scope — for example account:acme/** (everything in the account) or
account:acme/document:* (every document at the account root). Limit caps the
result; a non-positive Limit (or one above the bound) is clamped to the bound
the engine was configured with, so an enumeration can never materialise an
unbounded set. That bound is engine.WithEnumerateLimit's value, or
engine.DefaultEnumerateLimit (1000) on an engine built without it. Object order
is deterministic (sorted by canonical id).
ids, err := eng.Enumerate(ctx, engine.EnumerateRequest{
Account: "acme",
Principal: "alice",
Action: "read",
Pattern: "account:acme/project:atlas/**",
Limit: 100,
})
An operational failure — a storage fault, an unresolvable scope strategy, or an unconfigured object lister an implicit/exclusive grant needs — is returned as a coded error, never a silent partial set.
A result on the bound is a warning
A truncated result and a complete one are the same []string. When an
enumeration comes back holding exactly its effective bound, the engine
therefore says so through WithLogger (slog.Default() when none is wired):
WARN engine: enumeration returned exactly its bound; the result may be truncated
bound=100 configured_bound=1000 requested_limit=100
account=acme action=read pattern=account:acme/project:atlas/**
bound is the cap this enumeration actually ran under — your own Limit when
that was the smaller of the two — and configured_bound is the engine's ceiling.
A result below the bound logs nothing.
The wording is deliberate: this is a hint, not an assertion. A complete set
that happens to be exactly that size is indistinguishable from a truncated one
here, so the line never claims anything was dropped. Enumerate's return shape
is unchanged — there is no truncation flag, and a caller still cannot tell the
two apart. The intended response is an operator's, not a caller's: raise the
bound (or the request Limit) and ask again.
It is raised at the engine only. One bound governs the engine clamp, the scope
member gather (scope.Deps.MaxMembers) and the provider list alike, so a starve
further down surfaces here as a result sitting on the bound.
Filtering by object metadata
Fields is an optional set of object-metadata predicates. An allowed candidate
is returned only when its metadata satisfies all of them; a nil or empty map
(the default) filters nothing and never touches a metadata source, so an
unfiltered enumeration costs exactly what it always did.
ids, err := eng.Enumerate(ctx, engine.EnumerateRequest{
Account: "acme",
Principal: "alice",
Action: "read",
Pattern: "account:acme/**",
Fields: map[string]any{"tier": "premium", "brands": "brand:Y"},
Limit: 10,
})
The meaning is
provider.Filter's Fields contract
verbatim, evaluated by the same provider.MatchFields a provider's own Query
calls — so an enumeration filtered here and one filtered inside a provider select
the same objects. AND across keys; a collection field matches by
membership; an absent field never matches, not even a nil want; and
comparison is typed (int64(5) matches a float64(5) want, "5" does not
match 5).
Two orderings are load-bearing:
- Deny first. The predicate runs on candidates that already survived deny-overrides and specificity, so it can only subtract from the allowed set. A denied object is never returned, whatever the predicate says.
- Filter before
Limit. The candidate set is predicated before it is truncated, so asking for the first 10 objects taggedbrand:Ysearches every candidate rather than tagging the first 10 candidates and returning the few that stuck.
Metadata is read through engine.WithMetadata(fetcher), whose seam
(MetadataFetcher) has the signature of *provider.Registry.Fetch — the same
registry that backs the scope lister and the rule evaluator, so a candidate is
served from the per-type cache — warmed by the enumeration itself when the provider
promises its listed bags are its fetched bags
(FetchCompleteLister),
and by the candidate's own Fetch otherwise:
eng := engine.New(store,
engine.WithScopeResolution(nil, engine.ScopeDeps{Lister: reg, Rules: rulesEngine}),
engine.WithMetadata(reg))
Failure is deliberately asymmetric — an enumeration returning fewer objects reads as "no access", one returning more is an authorization bug:
- no metadata source wired, or no provider for the candidate's object-type →
APERTURE_PROVIDER_UNREGISTERED, never a silently empty result. The predicate runs per candidate, so this only surfaces once the enumeration has at least one allowed candidate; an empty allowed set returns empty regardless of wiring. - the object has no metadata row (
APERTURE_NOT_FOUNDfromFetch) → every field is absent, absent never matches, so the object is excluded. - any other provider failure → returned verbatim.
EnumerateBatch and EnumerateAs carry Fields through the same path.
Restricting through a declared reference
References restricts the enumeration to the identities a holder object's
declared reference field contains — "the brands in dataset X". Nil or empty
(the default) restricts nothing.
ids, err := eng.Enumerate(ctx, engine.EnumerateRequest{
Account: "acme",
Principal: "alice",
Action: "read",
Pattern: "account:acme/brand:*",
References: []engine.ReferenceEdge{{
HolderID: "account:acme/dataset:x", // HolderType is optional
Field: "current_brands", // must be a DECLARED reference
}},
})
It is a dereference, not a filter, and the distinction is the whole reason it
exists. Fields answers "which datasets contain brand Y?" because the dataset
holds current_brands. A brand holds no field naming its datasets — references
are declared on the holding side
only — so no predicate on brand
can express "which brands belong to dataset X?" at all.
Composition mirrors the filter's: several edges AND, an edge composes with
Fields, both apply before Limit, and the restriction can only subtract
from the allowed set. Exactly one hop is taken — the identities an edge
yields are never themselves dereferenced. The whole restriction is resolved
once per enumeration, before candidates are gathered, against the same grants
and subject set the candidates are decided with; that is what makes
EnumerateAs check the holder with the impersonated authority.
The failure modes are asymmetric, and the asymmetry is the security model:
| Situation | Result |
|---|---|
| The principal may not read the holder | Empty result, no error. "You may not see dataset X" and "dataset X contains nothing you may see" have to be indistinguishable, or the edge is an oracle for objects the caller was never allowed to know about. |
The holder is absent, inside Account, caller is a member | APERTURE_NOT_FOUND — the ergonomics a typo deserves, confined to a caller already inside the account. Existence is resolved before the holder's check precisely so this answer survives. |
The holder is outside Account | Empty, whether or not it exists. The disclosure boundary. |
| The caller is not a member | Empty, always — membership is decided before the holder is looked up. |
| A referenced identity no longer exists | Skipped, with a warning log naming it and a dangling_reference note that does not. An application-level foreign key has no database constraint behind it, so one deleted brand must not fail every decision on the dataset that still lists it. |
| The field is not declared, the holder type has no provider, or no reference source is wired | APERTURE_PROVIDER_REFERENCE_INVALID / APERTURE_PROVIDER_UNREGISTERED — loud, never an empty list. A wiring fault describes the deployment, not the data. |
| A reference value does not point at the declared target | APERTURE_PROVIDER_REFERENCE_MISMATCH — never a silently dropped value. |
Wire the source alongside the metadata source; it is the same registry:
eng := engine.New(store,
engine.WithScopeResolution(nil, engine.ScopeDeps{Lister: reg, Rules: rulesEngine}),
engine.WithMetadata(reg),
engine.WithReferences(reg),
engine.WithLogger(logger))
A rules-engine dereference is deliberately not supported: Check owes a p99
under a millisecond, and following a reference inside rule evaluation is a join
on the decision hot path with a recursive cache-miss path behind it. Enumeration
computes the restriction once, off that path.
EnumerateBatch and EnumerateAs carry References through the same path.
Search
Search resolves a name to an id. It is the operation to reach for when a
question arrives in a person's own words and every step after it needs ids.
matches, err := eng.Search(ctx, engine.SearchRequest{
Account: "acme",
Principal: "alice",
Action: "read",
Pattern: "account:acme/brand:*",
Query: "nike",
Limit: 5,
})
// matches[0].Object "account:acme/brand:42"
// matches[0].Score 0.875
// matches[0].Field "label"
// matches[0].Value "Nike, Inc."
// matches[0].Metadata map[string]any{"label": "Nike, Inc.", "sector": "Footwear"}
It is a ranking over Enumerate, not a second answer
Search does not re-derive who may see what. It walks the same candidate
gather, the same deny-overrides decision, the same reference restriction and the
same Fields predicate Enumerate walks, and only scores what survived.
Decide, then match — so a score can only ever subtract, and the result is
always a subset of what Enumerate would return for the same subject, action and
pattern.
That ordering is the feature. Composing the pieces yourself — enumerate a type, fetch each id's metadata, match in your own process — puts the unscoped set across the boundary first and narrows it second, so any bug in your filter turns your surface into an oracle for every object in the system.
Search selects; it never authorizes. A score is a ranking hint for
whoever is choosing between candidates. Nothing in Aperture reads one, and a
caller that acts on a selected id still Checks it.
What matches
Matching is case- and punctuation-insensitive ("Nike, Inc." matches
"nike inc") and tolerates a typo or a transposition on tokens long enough for
one to be unambiguous. Only string material is matched — a string field and the
string elements of a list field — because filtering by a number, bool or date is
what Fields is for.
Aperture has no notion of a "label": a label is an ordinary metadata field
whose name your host chose. Every field holding text is searched unless
MatchFields names some, and each match reports the Field and Value that
produced it, so a hit on an alias is distinguishable from a hit on a display
name.
Options
| Field | Meaning |
|---|---|
Query | The free text. Required — an empty query is APERTURE_INVALID_INPUT, not "match everything". |
MatchFields | Which metadata fields to match against. Empty searches every field holding text. |
Fields | The same metadata predicate EnumerateRequest.Fields carries. Narrows the candidate set; the query ranks what is left. |
References | The same reference edges EnumerateRequest.References carries, with the same fail-closed rules. |
MinScore | The score a match must reach, in [0,1]. <= 0 means provider.DefaultMinScore. |
Limit | Caps the returned matches — the top of a finished ranking. |
Limit caps the ranking; the bound caps the scan
These are two different numbers. The scan runs under the engine's configured
enumeration bound (WithEnumerateLimit), over the same candidate set Enumerate
covers. Limit then takes the top of the finished ranking.
Bounding the scan by Limit would return the first N objects that matched at
all rather than the N best — an arbitrary prefix with a score column, not a
ranking.
A scan that comes back holding exactly its bound is warned about through
WithLogger. That hint matters more here than for Enumerate: a truncated
enumeration returns a visibly short list, while a truncated search returns a
full, confidently ranked shortlist whose best entry may simply be the best among
the candidates that fit. As with Enumerate's warning it cannot be an assertion
— a complete set of exactly that size is indistinguishable.
Requirements and failure modes
Search requires an object-metadata source (WithMetadata), unlike the
Fields predicate, which is only consulted when a request carries one. Without
it the answer is APERTURE_PROVIDER_UNREGISTERED rather than an empty result,
because an empty result reads as "you may see nothing" and would hide the
misconfiguration behind a plausible answer.
A principal who is not a member of the account gets an empty result and no error; so does an invisible reference holder. A candidate with no metadata row is skipped — there is nothing there to name.
SearchAs is the impersonation-aware sibling, and SearchBatch resolves many
names in one call.
Explain
func (e *Engine) Explain(ctx context.Context, req Request) (Trace, error)
Explain resolves the request exactly as Check does but records the full
derivation instead of only the verdict. Use it as a diagnostic — the "why" behind
a verdict — not as an enforcement gate. It takes the same Request as Check.
Trace
Trace is a stable public contract: the RPC surface, the MCP inspect tool,
and the what-if simulator all serialize it, so its fields are part of the API.
type Trace struct {
Request Request // the question that was asked
Subjects []model.Subject // the principal's expanded subject set (itself, roles, groups)
Considered []GrantEvaluation // every grant loaded, each tagged with how it fared
MaxSpecificity int // top specificity among covering candidates; 0 when nothing covered
Notes []EvaluationNote // diagnostics rule evaluation recorded; empty when no rule ran
Attributes TraceAttributes // the `principal` and `account` bags the rules read; zero when no rule ran
Now time.Time // the reference instant rule evaluation resolved against; zero when no rule ran
Decision Decision // the final verdict — identical to what Check returns
Impersonation *ImpersonationContext // non-nil only under an active impersonation session
}
Each entry in Considered is a GrantEvaluation recording one grant's
contribution — its subject, permission, effect, object pattern, whether its
action matched, whether it covered the object and at what specificity, which
scope strategy it used, whether it was a deciding grant, and a short
human-readable Outcome. A grant that failed the action match is still listed
(with ActionMatched: false) so the trace shows what was ruled out.
Evaluation notes
Notes is where a rule-backed scope explains itself. A rule can decide false
for a reason the verdict never shows — the metadata field it reads is the wrong
shape (a string where an array was meant), or it matched only because the
field is absent. Both are deny-safe by policy: a collection operator over a
non-collection evaluates to false rather than raising APERTURE_RULE_EVAL, so
one mistyped field cannot break every decision that touches it. See
Rules.
type EvaluationNote struct {
GrantID string // the grant whose scope resolution recorded it
Rule string // the rule reference that was evaluated
Kind string // "shape_mismatch" | "absent_field" | "date_invalid" |
// "date_bounds_inverted" | "dangling_reference" | "attributes_floor_only"
Op string // the comparison operator ("hasAll", …)
Path string // the dotted variable path ("object.tags")
Expected string // the shape the operator requires ("collection")
Actual string // the shape found ("string")
Message string // "object.tags: expected collection, got string"
}
Six kinds are recorded today: shape_mismatch, absent_field, date_invalid,
date_bounds_inverted, dangling_reference (an enumeration skipped a declared
reference whose target no longer exists), and attributes_floor_only (a rule
read a host-defined field off principal or account while that root
carried nothing but the engine's floor, so every such comparison was false).
Three rules govern them:
- Diagnostic only — a note never influences a verdict.
Explainonly —CheckandEnumerateinstall no collector, so they record nothing, allocate nothing, and behave exactly as before.- Shape and path only — never a metadata value, never anything that could cross an account boundary, the same discipline error messages follow.
The attribute bags
Attributes carries the principal and account roots this decision's rules
were evaluated against — the host's bag with the engine's floor stamped over it,
exactly as a rule read it:
type TraceAttributes struct {
Principal map[string]any `json:"principal,omitempty"`
Account map[string]any `json:"account,omitempty"`
}
Both are nil on a decision that evaluated no rule, and Account is also nil for
a decision made at the account wildcard "*" — that is not an account, so no bag
is resolved for it and none is invented here.
This field deliberately discloses VALUES, and it is the one place a Trace
does. It is not an oversight to fix, and it does not contradict rule 3 above: a
note is produced by a comparison against an object's metadata, on whatever
objects a wide decision happened to sweep, while these two bags are the
subjects of the very question being asked — the principal in
Request.Principal and the account in Request.Account. A trace that discloses
them tells the asker about their own decision and nothing else.
The disclosure is also the point: "why was this denied?" is unanswerable from a
grant list when the deciding comparison was principal.tier == "gold" and the
operator cannot see that the tier is "silver", or absent entirely. So do not
redact these into a note, and do not widen them past those two subjects. The
wider-audience rule what-if
preview takes the opposite side on
purpose and supplies no principal or account input at all, so a rule author's
editor cannot become a directory read oracle.
Trace implements String(), which renders an operator-readable, deterministic
report:
tr, err := eng.Explain(ctx, engine.Request{
Account: "acme",
Principal: "alice",
Action: "read",
Object: "account:acme/project:atlas/document:42",
})
if err != nil {
return err
}
fmt.Print(tr.String())
tr.Decision is byte-for-byte the decision Check returns for the same request,
so a surface can render a verdict and its explanation from a single Explain
call. The rendered report prints each non-empty attribute root as one
key-sorted line before the grants — they are the input the grants' rules were
judged against — and stays byte-identical for the same decision.
When to use each
| Question | Operation |
|---|---|
| "May this principal do this one thing?" — an enforcement gate on the hot path. | Check |
| "Which of these objects may this principal act on?" — building a filtered listing or a picker. | Enumerate |
| "Which object is the user naming?" — a search box, a chat turn, an agent resolving a name to an id. | Search |
| "Why did that decision come out the way it did?" — a diagnostic, an audit view, a support tool. | Explain |
Check is the allocation-conscious hot path; reach for it in enforcement.
Enumerate is the most cache-sensitive op and is deliberately bounded — use it to
answer "what can they see", not as a substitute for repeated Checks on a known
object. Search is Enumerate plus a ranking, so it costs at least as much — reach for
it when the input is a name and for nothing else; when you already hold the ids,
Check or Enumerate is the cheaper question. Explain does the same work as
Check plus recording the derivation, so use it when a human (or a machine)
needs to understand the verdict, not on every hot-path call.
Related
- Batch operations — resolve many of these in one call.
- Impersonation — the
*Asvariants that resolve over an elevated subject set. - The service facade — the fail-closed wrapper surfaces call.
- Concepts primer — deny-overrides, specificity, and the subject set these operations resolve over.
Batch operations
Each of the three single decision operations has a bulk form that resolves many
requests in one call: CheckBatch, EnumerateBatch, and ExplainBatch. They
exist so a surface — the RPC bulk RPCs, the MCP tools, the what-if simulator — can
answer a list of questions in one round trip while keeping each answer isolated:
one bad query never fails the whole batch.
The batch forms live on both layers with the same shape. This page shows the
engine methods; the facade exposes the same three over its
surface-neutral Query / EnumerateQuery types.
BatchResult[T]
Every batch op returns a slice of BatchResult[T], aligned by index with the
input: result[i] is the outcome of reqs[i].
type BatchResult[T any] struct {
Result T // the item's answer when Err is nil; the zero value otherwise
Err error // the item's coded error when it failed, or nil on success
}
Exactly one of the two fields is meaningful per item. When Err is non-nil the
Result is the zero value and the caller reads Err; otherwise Result holds
the answer. The generic parameter is the per-op result type: Decision for
CheckBatch, []string for EnumerateBatch, and Trace for ExplainBatch.
Iterate a batch by checking each item's Err before reading its Result:
for i, item := range results {
if item.Err != nil {
log.Printf("query %d failed: %v", i, item.Err)
continue
}
use(item.Result)
}
CheckBatch
func (e *Engine) CheckBatch(ctx context.Context, reqs []Request) []BatchResult[Decision]
Resolves many Check requests, returning []BatchResult[Decision] aligned with
reqs. A request that errors yields an item with Err set and a zero Decision;
its siblings are unaffected. A nil reqs yields a nil result.
results := eng.CheckBatch(ctx, []engine.Request{
{Account: "acme", Principal: "alice", Action: "read", Object: "account:acme/project:atlas/document:42"},
{Account: "acme", Principal: "alice", Action: "write", Object: "account:acme/project:atlas/document:42"},
})
for i, item := range results {
if item.Err != nil {
continue // a malformed request — item.Result is the zero Decision
}
fmt.Printf("query %d: allow=%v\n", i, item.Result.Allow)
}
EnumerateBatch
func (e *Engine) EnumerateBatch(ctx context.Context, reqs []EnumerateRequest) []BatchResult[[]string]
Resolves many Enumerate requests, aligned with reqs — result[i] is the id
list for reqs[i]. A request that errors yields an item with Err set and a
nil list; the rest are unaffected.
results := eng.EnumerateBatch(ctx, []engine.EnumerateRequest{
{Account: "acme", Principal: "alice", Action: "read", Pattern: "account:acme/project:atlas/**"},
{Account: "acme", Principal: "alice", Action: "read", Pattern: "account:acme/project:nimbus/**"},
})
ExplainBatch
func (e *Engine) ExplainBatch(ctx context.Context, reqs []Request) []BatchResult[Trace]
Resolves many Explain requests, aligned with reqs. A request that errors
yields an item with Err set and a zero Trace; the rest are unaffected.
Facade batch forms
The service facade exposes the same three over its
surface-neutral query types, so a surface never touches the engine's Request
type:
func (s *Service) CheckBatch(ctx context.Context, qs []Query) []engine.BatchResult[Result]
func (s *Service) EnumerateBatch(ctx context.Context, qs []EnumerateQuery) []engine.BatchResult[[]string]
func (s *Service) ExplainBatch(ctx context.Context, qs []Query) []engine.BatchResult[engine.Trace]
Note where the fail-closed contract lands. Service.CheckBatch renders each item
exactly as Service.Check: an operational failure folds into a deny Result
(with Err nil), while an input-validation failure sets the item's Err. So a
CheckBatch item's Err is only ever a caller-bug error, never a storage fault
— the storage fault already became a fail-closed deny. EnumerateBatch and
ExplainBatch carry engine errors verbatim in each item's Err. See
The service facade for the full rendering rules.
Like the engine forms, every facade batch method returns nil for a nil input
slice.
Related
- Decision API — the single operations these batch.
- The service facade — the fail-closed rendering the facade batch forms inherit.
Impersonation
The engine exposes an impersonation-aware sibling of each decision operation:
CheckAs, EnumerateAs, and ExplainAs. They resolve a decision over an
effective subject set — the target's authority, borrowed by an operator — while
recording the real operator for audit. They never mutate stored grants; an
ImpersonationContext only steers which subject set the engine resolves over.
ImpersonationContext
The decorator that carries a session into the engine:
type ImpersonationContext struct {
RealActor string // the operator's principal id (the audit identity)
EffectiveSubject string // the target's principal id (whose authority is used)
Mode Mode // augment or become (or none, which is inert)
ExpiresAt time.Time // the session's hard expiry instant
}
- RealActor is the operator — the principal that truly issued the request and under whose identity audit attributes the action.
- EffectiveSubject is the target — the principal whose authority the decision borrows.
- Mode selects augment vs become (below).
- ExpiresAt is a hard time-box the engine enforces with its injected clock. A presented-but-expired context fails closed to no elevation (the operator's own authority), never to the target's.
Mode
const (
ModeNone Mode = "" // no impersonation — the inert zero value
ModeAugment Mode = "augment" // ADD the target's permissions to the operator's own
ModeBecome Mode = "become" // FULLY assume the target's identity for the decision
)
ModeAugmentresolves over the union of the operator's and the target's subject sets, but the operator keeps acting under its own identity. Use it to "see what they can see" while retaining your own authority.ModeBecomeresolves over the target's subject set alone, as if the target had asked — the operator's own grants do not apply. Become is the strictly stronger mode and is gated by a stronger right (see theimpersonationpackage). The audit trail still records the real operator.ModeNoneis the inert default: a zeroImpersonationContextconfers no elevation, and the*Asoperation delegates straight to its plain sibling.
Mode.Valid() reports whether a mode is recognised (none counts as valid).
The operations
func (e *Engine) CheckAs(ctx context.Context, req Request, ic ImpersonationContext) (Decision, error)
func (e *Engine) EnumerateAs(ctx context.Context, req EnumerateRequest, ic ImpersonationContext) ([]string, error)
func (e *Engine) ExplainAs(ctx context.Context, req Request, ic ImpersonationContext) (Trace, error)
Each takes the same request its plain sibling takes, plus the
ImpersonationContext. Behaviour is identical to the plain operation except for
the subject set the decision resolves over.
Rules for an active session
An ImpersonationContext is active when its mode is augment or become and its
ExpiresAt is still in the future (per the engine's clock). For an active
session:
- The request's principal must be the operator:
req.Principal == ic.RealActor. A mismatch is a caller bug and surfaces asAPERTURE_INVALID_INPUT, not a deny. - Augment resolves over operator ∪ target subjects; become resolves over the target alone.
- A rule-backed scope strategy is told about the effective subject: under
becomethe rule'sprincipal.idis the target's id and the target's kind picks the attribute slotprincipal.*is read from; underaugmentboth are the operator's, because the operator keeps acting under its own identity. The rule and the grant set therefore always describe the same principal — see Underbecome,principal.*is the target. - The operator and the target must both be members of the active account,
else the decision is a fail-closed deny — cross-account impersonation is
refused. (
CheckAs/ExplainAsreturn a deny with no deciding grant;EnumerateAsreturns the empty set.) - The returned
Decision/Tracecarriesicon itsImpersonationfield for audit. In aTrace,Subjectsis the effective subject set whileRequest.Principalremains the real operator — so a trace shows both who asked and whose authority answered.
Under become, principal.* is the target
The subject set and the rule must describe the same principal. A decision whose grants are the target's and whose rule is about the operator answers a question nobody asked, and nothing in a trace reveals it.
| Subject set | principal.id / principal.* | Request.Principal | Impersonation.RealActor | |
|---|---|---|---|---|
augment | operator ∪ target | operator | operator | operator |
become | target alone | target | operator | operator |
principal.id therefore changes meaning under become: a rule comparing
principal.id == object.owner asks whether the target owns the object, which
is what "as if the target had asked" means. The target's kind also picks the
attribute slot, so principal.tier is read from the target's directory.
Audit is unaffected in both modes. Request.Principal is still the operator —
it is what the deny reason names and what a Trace records — and
Decision.Impersonation / Trace.Impersonation still carry the real actor. The
rule sees whose authority answered; the audit trail sees who acted.
The change is free: the target's kind arrives with the subject-set expansion a
become decision already performs, so it buys no extra storage read.
Inert sessions fail closed
When ic is inert — mode none, or an expired session — the *As operation
delegates straight to its plain sibling. An expired become session therefore
resolves as the operator's own authority with no elevation, never as the
target's. Elevation never outlives its time-box.
ic := engine.ImpersonationContext{
RealActor: "alice", // the operator issuing the request
EffectiveSubject: "bob", // the target whose access is borrowed
Mode: engine.ModeBecome,
ExpiresAt: time.Now().Add(15 * time.Minute),
}
dec, err := eng.CheckAs(ctx, engine.Request{
Account: "acme",
Principal: "alice", // MUST equal ic.RealActor
Action: "read",
Object: "account:acme/project:atlas/document:42",
}, ic)
if err != nil {
return err
}
// dec resolves over bob's authority; dec.Impersonation records alice as the real actor.
fmt.Println(dec.Allow, dec.Impersonation.RealActor)
Carrying impersonation on the context
The engine also exposes context helpers so a middleware layer — most importantly the audit layer — can read the real actor and effective subject of any decision made while a session is set:
func WithImpersonation(ctx context.Context, ic ImpersonationContext) context.Context
func ImpersonationFromContext(ctx context.Context) (ImpersonationContext, bool)
The *As entry points set this on the context they evaluate under; a surface may
also set it before calling so audit middleware wrapping the engine sees it.
Related
- Decision API — the plain operations these mirror.
- The service facade — surfaces reach impersonation through
the facade's impersonation service (wired with
WithImpersonation). - The
impersonationpackage — session issuance and the rights that gate augment vs become.
The service facade
The service package is the thin decision facade every surface calls instead
of touching the engine directly — the CLI check command, the HTTP /check
endpoint, the Twirp service, and the MCP read subset. It exists so those surfaces
share one code path with one fail-closed policy: the rule for turning an
engine error into a rendered decision lives here, not duplicated per surface.
If you are embedding Aperture to build your own surface, call the facade rather
than the raw engine — you inherit its fail-closed contract,
decision auditing, and the what-if Simulate path for free.
Constructing the facade
func New(eng *engine.Engine, opts ...Option) *Service
With no options a Service is read-only: it carries the decision API (Check
/ Enumerate / Explain and their batch forms) always, and returns
APERTURE_UNIMPLEMENTED from any mutation. Options wire the additional
dependencies:
| Option | Enables |
|---|---|
WithStorage(store model.Storage) | Entity-CRUD mutations and their reads; also the base store the Simulate overlay layers onto. |
WithGate(gate *authz.Gate) | The admin-authority gate consulted before every system/account-tier mutation. |
WithDelegation(d *delegation.Service) | Bestow / Revoke. |
WithImpersonation(i *impersonation.Service) | ImpersonationStart / session issuance. |
WithAudit(r *audit.Recorder) | The append-only audit trail: mutations synchronously, decision checks sampled + async. |
WithProviders(reg *provider.Registry) | ObjectIdentifiers and ObjectMetadata — object enumeration and metadata reads. |
WithAttributes(reg *provider.AttributeRegistry) | ListAttributes and the three attribute-cache invalidations — the gated, system-tier directory reads. It grants nobody anything: the decision path never resolves a bag through this option. |
WithWiringHealth(h *service.WiringHealth) | WiringPosture — the gated, system-tier read of whether this instance's background re-read of the shared wiring is failing, and for how long. The recorder is created by whatever starts the refresher and the SAME pointer is handed to both, or the facade answers for a loop that never wrote to it. It is seeded with, and advanced by, the boot's digest PAIR (service.WiringDigests — the shared tables' digest and the local document's), which is one value so that no refresh can advance half of it. Unwired, WiringPosture reports the zero posture (not polling, not stale), which is the truth for a process with no refresher. |
WithRuleSource(base rules.RuleSource, fetcher rules.MetadataFetcher) | The what-if preview of an unsaved rule via Simulate's Overlay.Rules. |
WithDeclaredAttributeKeys(sets map[provider.AttributeSlot]model.DeclaredKeys) | The wiring's declared attribute key sets. Turns on definition-time key enforcement: ValidateRule, PutRule, EvaluateRulePreview and Import refuse a rule reading a key a declaring slot does not name, with APERTURE_RULE_UNDECLARED_ATTRIBUTE. Import refuses the document whole, before the transaction opens. This option is only the authoring half of declared_keys: — the same list also reserves its keys to a slot's shared attribute layer on the decision path, which is provider's doing and not the facade's. See skills/attribute-providers.md. Opt-in per slot — without it, and for a slot that declares nothing, every rule that validated before still validates. It reaches no decision. |
WithClock(now func() time.Time) | Override the facade clock used to stamp entity timestamps on writes (for deterministic tests). |
The serve command builds the fully-wired facade so HTTP, Twirp, and the CLI all
drive one mutation path. A decision-only surface can stay minimal with
service.New(eng).
Surface-neutral query types
The facade takes Query / EnumerateQuery — surface-neutral mirrors of the
engine's request types — so the CLI and HTTP layers marshal to and from these and
the engine's Request stays an engine-internal concern.
type Query struct {
Account string
Principal string
Action string
Object string
}
type Result struct {
Allow bool // the verdict
Reason string // names the deciding grants, or the fail-closed cause
DecidingGrantIDs []string // empty on a default-deny or a fail-closed deny
}
type EnumerateQuery struct {
Account string
Principal string
Action string
Pattern string
Fields map[string]any // optional object-metadata predicates; nil filters nothing
References []ReferenceEdge // optional reference edges; nil restricts nothing
Limit int
}
type ReferenceEdge struct {
HolderType string // optional; empty means HolderID's terminal segment type
HolderID string // "account:acme/dataset:7"
Field string // a DECLARED reference field on the holder
}
EnumerateQuery.Fields — the object-metadata filter
Fields narrows an enumeration by object metadata: an object the principal is
allowed to act on is returned only when its metadata satisfies every
predicate. Nil or empty — the default — filters nothing and does not consult a
metadata source at all, so existing callers are unaffected.
The facade passes the map to the engine unchanged. It parses nothing, coerces
nothing, and normalises nothing, and it never rewrites the caller's map. That is
deliberate: the predicate's meaning is
provider.Filter's Fields contract,
evaluated in exactly one place (provider.MatchFields). A surface that
re-interpreted a value on the way in — parsing "5" into a number, say — would
make an enumeration select objects a Check then denies.
ids, err := svc.Enumerate(ctx, service.EnumerateQuery{
Account: "acme",
Principal: "alice",
Action: "read",
Pattern: "account:acme/**",
Fields: map[string]any{"tier": "premium", "brands": "brand:Y"},
Limit: 10,
})
The semantics a caller must know:
- AND across keys. Every predicate must hold.
- Collections match by membership. A list-valued metadata field matches when it contains the wanted value; a list-valued want is a container compared by equality.
- An absent field never matches — not even against a
nilwant. - Comparison is typed.
int(5)andfloat64(5)are one value;"5"is not5. - The filter runs before
Limit. Candidates are decided, then filtered, then truncated — so "the first 10 objects taggedbrand:Y" searches every candidate. Truncating first would return a silently wrong answer. - The filter only subtracts. It applies to candidates that already survived
deny-overrides, so no predicate can surface an object
Checkwould deny.
Failure is asymmetric on purpose, because a short answer reads as "no access":
| Situation | Result |
|---|---|
| No metadata source wired, or no provider for the candidate's object-type | APERTURE_PROVIDER_UNREGISTERED — never a silently empty list. Because the predicate runs per candidate, this only surfaces when the enumeration has at least one allowed candidate; an empty allowed set returns empty regardless of wiring. |
The provider has no row for the object (APERTURE_NOT_FOUND from Fetch) | The object is excluded — every field is absent, and absent never matches. |
| Any other provider failure | Surfaced verbatim. |
Wire the source alongside the scope lister — it is the same registry:
eng := engine.New(store,
engine.WithScopeResolution(nil, engine.ScopeDeps{Lister: reg, Rules: rulesEngine}),
engine.WithMetadata(reg))
Two notes for anyone refactoring this field:
- Over Twirp,
Fieldsrides asmap<string, google.protobuf.Value>, which carries every number as a double. An integer beyond 2^53 loses precision in transit and must be sent (and stored) as a string; a non-finite number (NaN, ±Inf) is rejected asAPERTURE_INVALID_INPUT. See the RPC reference. Fieldscarriesjsonandjsonschemastruct tags — the only tagged field on anyservicetype. The MCP surface aliases this struct (mcp.EnumerateIn) and reflects its tool schema off it, so theomitemptyis load-bearing: without it the reflected schema marks the predicate required and an agent cannot ask an unfiltered question at all.
EnumerateQuery.References — the reference edges
References restricts an enumeration to the identities a holder object's
declared reference field contains — "the brands in dataset X". Nil or empty
— the default — restricts nothing.
ids, err := svc.Enumerate(ctx, service.EnumerateQuery{
Account: "acme",
Principal: "alice",
Action: "read",
Pattern: "account:acme/brand:*",
References: []service.ReferenceEdge{{
HolderID: "account:acme/dataset:x",
Field: "current_brands",
}},
})
It is a dereference, not a predicate, which is why it is not spelled through
Fields: a brand carries no field naming its datasets, so no predicate on brand
can express the question. Fields answers the mirror image ("which datasets
contain brand Y?") because that side holds the field. The declaration side is
Declared references.
The facade carries the edges to the engine unchanged — it does not parse a holder identity, infer a holder type, or check that a field is declared. Every one of those is a decision with a disclosure consequence, and a surface that made it separately would be a second place for the answer to differ.
HolderTypeis optional. Empty means "whateverHolderID's terminal segment type is"; when given it must agree withHolderID, and the engine — not this type — enforces that.- Several edges AND, an edge composes with
Fields, and both precedeLimit. - Exactly one hop. The identities an edge yields are never themselves dereferenced.
- The restriction is computed once per enumeration, against the same grants
and subject set the candidates are decided with — so
EnumerateAschecks the holder with the impersonated authority, not the operator's.
The failure modes are asymmetric on purpose, and the asymmetry is the security model:
| Situation | Result |
|---|---|
| The principal may not read the holder | Empty result, no error. "You may not see dataset X" and "dataset X contains nothing you may see" must be indistinguishable, or the edge is an oracle for objects the caller was never allowed to know about. |
The holder is absent, inside Account, and the caller is a member | APERTURE_NOT_FOUND — the ergonomics a typo deserves, confined to a caller already inside the account. |
The holder is outside Account | Empty, whether or not it exists. This is the disclosure boundary. |
The caller is not a member of Account | Empty, always — membership is decided before the holder is looked up. |
| A referenced identity no longer exists | Skipped, with a warning log and a dangling_reference note; never a failed decision. |
| The field is not a declared reference | APERTURE_PROVIDER_REFERENCE_INVALID — loud, never an empty list that would read as "no access". |
| The holder's type has no provider, or the engine has no reference source | APERTURE_PROVIDER_UNREGISTERED — likewise loud. |
Wire the source alongside the metadata source — it is the same registry again:
eng := engine.New(store,
engine.WithScopeResolution(nil, engine.ScopeDeps{Lister: reg, Rules: rulesEngine}),
engine.WithMetadata(reg),
engine.WithReferences(reg),
engine.WithLogger(logger)) // where a dangling reference is reported
References carries the same json / jsonschema tags Fields does, for the
same MCP-reflection reason: omitempty on the slice keeps the edges optional,
while HolderID and Field are required properties of an edge and HolderType
is not.
SearchQuery — resolving a name to an id
SearchQuery is EnumerateQuery plus a name, and Search returns
[]SearchMatch:
matches, err := svc.Search(ctx, service.SearchQuery{
Account: "acme", Principal: "alice", Action: "read",
Pattern: "account:acme/brand:*",
Query: "nike",
Limit: 5,
})
| Field | Meaning |
|---|---|
Query | The free text. Required; an empty query is APERTURE_INVALID_INPUT, never "match everything". |
MatchFields | Metadata field names to restrict matching to. Empty searches every field holding text. |
Fields / References / Limit | Exactly what they mean on EnumerateQuery, except that Limit caps the finished ranking rather than the scan. |
MinScore | The score floor, [0,1]; <= 0 means provider.DefaultMinScore. |
Each SearchMatch carries Object, Score, the Field and Value that
produced the score, and the object's full Metadata inline — so labelling a
result set costs no further call.
The facade passes every field to the engine unchanged. In particular no surface normalises the query text: case folding, punctuation stripping and tokenising happen in exactly one place, so a name typed at the CLI, sent over MCP, and passed in Go score identically.
Two properties are worth restating because they are what make the surface safe:
candidates are decided before they are scored, so a result is always a subset
of Enumerate's for the same subject; and a score ranks but never
authorizes, so a caller acting on a selected id still Checks it. Search
requires engine.WithMetadata — without it the answer is
APERTURE_PROVIDER_UNREGISTERED, never an empty list. See
the decision API.
ObjectMetadata and ObjectMetadataBatch
ObjectMetadata(objectID) returns one object's provider metadata;
ObjectMetadataBatch(objectIDs) returns many, aligned by index as
[]engine.BatchResult[map[string]any], so a malformed or absent id carries its
own error without failing its siblings. Both require WithProviders.
Neither authorizes or filters — they label ids a caller already holds. The path
that produces ids a subject may see is Enumerate, or Search when the input
is a name; and since Search already returns metadata inline, most callers never
need the batch at all.
Fail-closed rendering
The facade's reason for existing is one shared policy for turning an engine outcome into a rendered decision:
| Engine outcome | Facade renders it as |
|---|---|
| A clean decision | Passes through unchanged. |
An input-validation error (APERTURE_INVALID_INPUT / APERTURE_IDENTITY_INVALID) | Returned to the caller verbatim — the caller asked an ill-formed question, so the CLI renders a usage error and HTTP returns 400. Not a deny. |
| Any other engine error (unknown principal, storage fault, ...) | Folded fail-closed into a deny Result (Allow: false, cause in Reason, Err nil). A decision point must never fail open. |
This rule is applied per operation as follows.
Check (fail-closed)
func (s *Service) Check(ctx context.Context, q Query) (Result, error)
Check returns an error only for a genuine input-validation failure; every
other engine failure folds into a fail-closed deny Result with a nil error.
On a clean render the decision is audited (sampled, asynchronous, off the hot
path).
res, err := svc.Check(ctx, service.Query{
Account: "acme",
Principal: "alice",
Action: "read",
Object: "account:acme/project:atlas/document:42",
})
if err != nil {
// only a malformed query reaches here (APERTURE_INVALID_INPUT / _IDENTITY_INVALID)
return err
}
fmt.Println(res.Allow, res.Reason) // an operational failure is res.Allow == false, err == nil
Enumerate and Explain (verbatim errors)
func (s *Service) Enumerate(ctx context.Context, q EnumerateQuery) ([]string, error)
func (s *Service) Explain(ctx context.Context, q Query) (engine.Trace, error)
Enumerate and Explain return engine errors verbatim for the surface to map
to a status. Enumerate cannot fail open by construction — every id it returns is
one Check allows — so an operational failure is a returned error, not a silent
partial set. Explain is a diagnostic, not an enforcement gate; its
engine.Trace is the public contract surfaces serialize.
That trace carries Notes — six kinds today: shape_mismatch, absent_field,
date_invalid, date_bounds_inverted, dangling_reference, and
attributes_floor_only — and it carries
Attributes, the principal and account
bags the rules were evaluated against, values included. That is a deliberate
disclosure and the one place a trace carries values: the two bags are the
subjects of the very request being explained. Callers gate Explain accordingly.
Batch forms
CheckBatch, EnumerateBatch, and ExplainBatch return per-item
engine.BatchResult[T] aligned with their queries. CheckBatch renders each item
exactly as Check (operational error → deny Result; input-validation error →
item Err); the other two carry engine errors verbatim per item. See
Batch operations.
Simulate — what-if
The facade adds a read-only what-if surface: Simulate and SimulateExplain
answer "what would the decision be if these hypothetical entities existed?"
without ever persisting them. It is the seam the MCP Simulate tool and the what-if
simulator UI drive.
func (s *Service) Simulate(ctx context.Context, ov Overlay, q Query) (Result, error)
func (s *Service) SimulateExplain(ctx context.Context, ov Overlay, q Query) (engine.Trace, error)
Both require the entity surface (WithStorage) so there is a base store to
overlay. Simulate carries the same fail-closed contract as Check;
SimulateExplain returns the trace verbatim like Explain. Nothing is written
and nothing is audited — a simulation is not a real decision.
The overlay
Overlay is the set of hypothetical entities a run layers over the live model.
Every field is additive and optional; an overlay entity with the same id as a
stored one shadows it (so a what-if can model an edited grant or a re-roled
principal), and ids absent from the overlay fall through to storage.
type Overlay struct {
Principals []model.Principal // hypothetical or shadowing principals
Groups []model.Group // hypothetical groups (union with stored memberships)
Permissions []model.Permission // hypothetical or shadowing permissions
Grants []model.Grant // the hypothetical grants — the common what-if input
Memberships []model.Membership // hypothetical account memberships (consulted under enforcement)
Rules []model.Rule // an unsaved rule being previewed (needs WithRuleSource)
}
The mechanism is structural, not conventional: Simulate builds a transient
engine (e.WithStore(overlay) — same coverer, membership policy, and clock as the
live engine, just a different read source) whose overlay store's writes are all
inert. A simulation physically cannot persist through it.
Worked example: "what if I bestowed this grant?"
Suppose bob currently cannot read document:42, and you want to preview the
effect of a new allow grant before bestowing it. Layer the hypothetical grant (and
the permission it references, if not already stored) into an Overlay and ask
SimulateExplain — the trace shows which hypothetical grant decided the
verdict.
import (
"github.com/frankbardon/aperture/model"
"github.com/frankbardon/aperture/service"
)
ov := service.Overlay{
Grants: []model.Grant{{
ID: "sim-grant-1",
AccountID: "acme",
Subject: model.Subject{Kind: model.SubjectPrincipal, ID: "bob"},
PermissionID: "perm-doc-read",
Effect: model.EffectAllow,
Object: "account:acme/project:atlas/**",
}},
}
tr, err := svc.SimulateExplain(ctx, ov, service.Query{
Account: "acme",
Principal: "bob",
Action: "read",
Object: "account:acme/project:atlas/document:42",
})
if err != nil {
return err
}
fmt.Print(tr.String()) // shows sim-grant-1 as the deciding grant — nothing was written
Because Simulate reuses the engine's exact resolution, a hypothetical deny
overlay grant correctly carves out a stored allow, and a shadowing principal
models "what if alice had role X" — all without a write.
SimulateExplain returns the same engine.Trace Explain does, attribute bags
included, because it runs the live engine over an overlay store. One nuance:
when the overlay carries unsaved rules, those rules are evaluated through a
transient rule engine built for the overlay alone, which carries no attribute
resolvers — so a previewed rule reads the floor bags (principal.{id, kind},
account.{id}) and nothing else, and earns an attributes_floor_only note if it
names a host-defined field. A simulation cannot inject an attribute bag either:
Overlay has no field for one.
Related what-if reads
Two adjacent reads support the rule-builder's what-if and require
WithProviders:
-
ObjectMetadata(ctx, objectID) (map[string]any, error)— the provider metadata a rule preview evaluates against. -
EvaluateRule(ctx, ast *rules.Node, objectID) (bool, map[string]any, error)— compiles an unsaved rule AST and evaluates it against one object's metadata, returning the boolean result and the metadata snapshot it saw. -
EvaluateRulePreview(ctx, ast *rules.Node, objectID) (RulePreview, error)— the same evaluation with the diagnostics a rule builder renders:Result,Object,Now(the reference instant, from the facade clock),Bounds(each relative-date operand and the concrete date it resolved to atNow), andNotes(the evaluation's deny-safe observations).EvaluateRuleis its narrow projection.Unlike
Check/Enumerate/Explain, this path compiles the AST directly instead of going through the decision engine, so it must supply the reference instant itself — arules.Inputwith a zeroNowhas none, and every relative date correctly resolves to nothing.It also supplies no principal and no account input at all — not even the engine's floor. A rule reading
principal.tierin the preview sees nothing, and noattributes_floor_onlynote is recorded, because there is no resolver behind it whose silence could be reported. This is the disclosure boundary of the whole preview surface: it is the wider-audience one (a rule author's editor), and handing it a principal bag would turn "evaluate this draft rule" into a read oracle for the principal directory — name a subject, compare a field, read the answer off the verdict.Explaintakes the opposite side on purpose.
ObjectIdentifiers(ctx, objectType, exclude...) (also WithProviders) enumerates
a type's complete instance set minus any excluded ids — the positive allow-list an
exclusive allowance materialises to.
The attribute directory — a system-tier read
WithAttributes adds the one administrative door onto an attribute slot.
Listing the user slot returns the host's whole user table, keys and bags
together, so every method here is gated directly through
authz.Gate.RequireSystemAdmin — the shape Export uses — rather than through a
mutation tier. Nothing here writes, and nothing here is audited.
recs, err := svc.ListAttributes(ctx, actor, "user", provider.AttributeFilter{
Fields: map[string]any{"department": "eng"},
Limit: 100,
})
| Method | Does |
|---|---|
ListAttributes(ctx, actor, slot, filter) | a page of one slot's directory, narrowed by the same Fields predicate Enumerate applies to object metadata |
InvalidateAttribute(ctx, actor, slot, id) | drop one subject's cached bag; reports whether one was present |
InvalidateAttributeSlot(ctx, actor, slot) | drop a whole slot's cache |
InvalidateAllAttributes(ctx, actor) | drop every slot's cache; names no slot, so it cannot probe which exist |
ExplainAttributeAuthority(ctx, actor) | the engine.Trace behind the authority decision above — deliberately not gated on holding that authority, or only the operators who were allowed could find out why the refused ones were not |
Three properties are contract, not implementation:
- The gate runs before the slot is parsed. A refused caller gets the
identical
APERTURE_AUTHZ_DENIED— nil slice, no count, no partial page — for a populated slot, an unregistered slot, and a slot name that does not exist, so a refusal cannot be used to probe which directories a deployment wires. A system-admin does get the real diagnostics. - No gate wired means
APERTURE_UNIMPLEMENTED, never "unrestricted": a bulk directory read has no narrower fallback to degrade to. - The decision path's fetch is not gated, and must never be. A decision resolves one bag for a subject it already named, through the rules engine's resolvers — never through this option. The two paths reach the same registry through different seams.
Invalidation is a security control, not a performance knob: a slot's TTL is the window a revoked clearance keeps authorizing for, and these methods are how an operator closes it now instead of waiting it out. They clear the caches of this process only.
The wiring posture — a system-tier read
An instance can be told to re-read the shared wiring tables on an interval
(--wiring-poll), so a aperture wiring push from another host is noticed
without a restart. That re-read can fail: the store is unreachable, the rows it
returns cannot be turned into a working registry, or a connection name in them is
one this host has no route for.
When it fails the instance keeps the wiring it already has and keeps deciding. It has to — Aperture is embedded in-process in its first real host, so an access engine that stops answering takes the whole host down with it, and a fleet that stops answering because one operator pushed a bad row is worse than a fleet answering from wiring one push behind.
The price is staleness, and staleness is never silent. Every failure emits an
APERTURE_* coded alarm, and the state is readable without touching a log:
p, err := svc.WiringPosture(ctx, actor) // system-admin
if p.Stale {
log.Printf("wiring stale for %s (%d failures, %s)", p.StaleFor, p.Failures, p.Code)
}
| Field | Is |
|---|---|
Polling / Every | whether this process re-reads at all, and how often. Both zero-valued for a boot-only instance, which cannot be stale in this sense because it never looks again. |
Stale / Healthy() | the most recent refresh attempt FAILED. It does not say the deployed wiring changed — a failed read cannot know, which is exactly why the instance keeps what it has. |
StaleFor / Since | how long the current run of failures has lasted, and when it began. StaleFor is the field to alert on: one missed tick against a restarting database is ordinary, and the same alarm four hours old is a fleet enforcing policy somebody already retired. |
Failures | consecutive failures in the current run. A long duration with one is a loop that stopped looking; with many, a store that keeps refusing. |
Code / Reason | the most recent failure's own APERTURE_* code and message — the store's APERTURE_STORAGE_SCHEMA_INCOMPATIBLE, APERTURE_WIRING_CONNECTION_UNROUTED, and only APERTURE_WIRING_REFRESH_FAILED when nothing beneath it was coded. Following the underlying code's fixups is the remedy. |
Digest / LastRefresh | the digest of the SHARED wiring this process is DECIDING FROM (the last-good set, when stale), and when a refresh last succeeded. It covers the five shared tables and nothing else, which is what keeps it comparable with what a push wrote and with what aperture wiring diff reports. |
LocalDigest | the digest of the LOCAL document the version this process is deciding through was built from — its own --seed file — and empty when it has none, the state of every instance wired only from the shared tables. |
Two digests, and why they are two fields
Digest answers "did this instance get the push?" and has to stay comparable with
what aperture wiring push wrote and with what aperture wiring diff reports, so
nothing local may be folded into it. But a rebuild also re-reads this instance's
own document, and that document decides things — inline objects: metadata,
inline attributes: bags, the declared attribute-key sets taken from them, and on a
deployment that has pushed nothing at all the whole of the wiring. So Digest alone
left two instances able to report the identical value and return different
verdicts.
LocalDigest is the second value that closes it. Sweep both: equal Digest
means the same push landed; equal Digest and equal LocalDigest means the two
instances were built from the same configuration.
Three things to know about it:
- It moves with an ADOPTION, exactly as
Digestdoes. It names the document the RUNNING version was built from, not whatever is on disk now and not what the process booted on — a value captured once at boot would be wrong in precisely the case the field exists to expose. - The two advance together or not at all. They are recorded as one pair
(
service.WiringDigests), taken from the version that was installed, so a posture can never name a combination no version was built from. A failed refresh moves neither. - It is a content digest and carries nothing. It digests the parsed document, so a reformatted-but-equivalent file digests the same; it is a hash, so it carries no path, no account, no principal, no object id and no attribute key or value.
Five properties are contract, not implementation:
- It is not part of
Capabilities, deliberately.Capabilitiesis an open, unauthenticated call because it carries booleans of immutable boot-time configuration and cannot fail. This is mutable runtime state about a fault whose useful half is a duration, and "this instance has been enforcing configuration its operator already replaced, for four hours" is operational intelligence for the operator, not for every anonymous caller. Adding a boolean there and keeping the duration here would be theatre — the boolean carries the disclosure. - The gate runs before the recorder is consulted. A refused caller's error is identical for a healthy instance, one that does not poll, and one four hours stale, so a refusal cannot be used to probe whether an instance is degraded.
- An unwired recorder is an answer, not a refusal. Every deployment that has not opted into polling reports not-polling / not-stale, which is true. Refusing would make the read unusable as a fleet-wide probe.
- Recovery clears it completely. Any completed refresh — one that observed no change, which is almost every tick, or one that adopted a change — resets the window, the count and the code. An alarm that latches past its own remedy trains an operator to ignore the channel.
- A refresh that read the tables and then refused to ADOPT them has completed nothing. It keeps the alarm, and the window keeps its original start, so a push refused for four hours reports four hours and four failures rather than one failure a tick old. Clearing on the strength of the read alone is the subtle version of reporting a knowingly-superseded instance as healthy: the alarm still fires, but the number the operator escalates on is reset on every tick.
Over Twirp this is WiringPosture, which takes an Actor (system-admin authority
resolves in an active account) and renders durations as Go duration text and
instants as RFC3339.
Related
- Decision API — the raw engine operations the facade wraps.
- Batch operations — the facade's per-item batch rendering.
- Impersonation — the engine's
*Asoperations. - Library quickstart — the facade wired end to end.
RPC / HTTP overview
Audience: engineers integrating a non-CLI consumer (a service, a script, or
the admin UI) against a running aperture serve.
Aperture exposes its full access-control API over HTTP as a
Twirp service. Twirp is a plain
request/response RPC framework: every method is an HTTP POST to a fixed URL,
with a JSON (or protobuf) body and a JSON (or protobuf) reply. There is no
streaming, no custom verbs, and no URL-encoded parameters — just one POST per
call. This makes the surface trivially reachable from curl, any HTTP client,
or a generated Twirp stub.
The service facade is the one code path
Everything on the wire is a thin translation onto the service.Service facade —
the same facade the CLI drives and the same one the admin UI calls. HTTP / Twirp
/ CLI therefore share one decision engine, one mutation path, and one
auth + admin-tier policy. A handler decodes the request, calls exactly one facade
method, and encodes the result; there is no business logic in the transport
layer (internal/server/).
Concretely, internal/server/twirp.go implements the generated
rpc.ApertureService interface, and each method body is a few lines: decode,
call h.svc.<Method>(...), encode. If a behaviour is not described here, it is
governed by the facade and documented under
The service facade.
Transport and endpoints
The Twirp service is mounted on a net/http ServeMux in
internal/server/server.go, under a fixed base path:
/twirp/aperture.ApertureService/<Method>
- Method is the RPC name exactly as it appears in the proto (
Check,PutGrant,Enumerate, …). - Content-Type selects the codec:
application/jsonfor JSON bodies (the form shown throughout these docs) orapplication/protobuffor the binary form. Both are the standard Twirp codecs; the JSON encoding is identical to the library's own JSON. - HTTP status is always
200for a successful call — including a decision of deny, which is a successful answer, not an error. Failures map an Aperture coded error onto a Twirp error code and its HTTP status (see Errors below).
Two smaller routes share the same mux for convenience:
| Route | Purpose |
|---|---|
POST /check | The minimal plain-HTTP decision path (a single Check), preserved so the simplest decision call needs no Twirp client. It calls the same facade with identical fail-closed semantics. |
GET /healthz | Liveness probe; returns 200 ok. |
GET / (and everything more specific losing to the API routes) | The embedded admin UI shell (documented in the Admin UI chapter). |
The server is started by aperture serve, which listens on
--addr (default :8080) and wraps the whole mux in the authentication
middleware.
service.proto is the source of truth
The canonical, machine-readable contract is
internal/wire/rpc/service.proto.
It declares roughly 60 RPCs and every request/response message. The committed
service.pb.go / service.twirp.go are generated from it (make proto).
The reference in the next page is hand-authored: generating a page from the proto is out of scope, so the catalog summarises each RPC's purpose and points back to the proto for exact field lists. When in doubt about a field name or a message shape, read the proto — it is authoritative. If an RPC is added to the proto and this chapter is not updated, the proto wins; treat any discrepancy as a docs bug, not a contract change.
Auth model
Authentication is applied as net/http middleware (server.Authenticate,
internal/server/middleware.go), wired in front of the whole mux by the serve
command. It reads a bearer credential from the Authorization header and, on
success, attaches the resolved Aperture principal to the request context:
- No credential → the request proceeds anonymously (no principal in context).
- A valid credential → the resolved principal is attached and the request proceeds as that identity.
- A bad credential → the request is refused
401with a coded error (APERTURE_INVALID_TOKEN/APERTURE_UNAUTHENTICATED). A bad token is a hard failure, never silently downgraded to anonymous.
The authenticator adapter is chosen by --auth / APERTURE_AUTH_MODE; the
default dev adapter treats the bearer token as the principal id, so Aperture
runs with no external IdP out of the box (oidc and parsec are opt-in). See
serve.
On top of that, each RPC enforces its own requirement, owned by the Twirp handler and the facade gate:
| Class of RPC | Requirement |
|---|---|
Decision RPCs (Check, Enumerate, Explain, and their batch forms) | Open — no authenticated principal required. This preserves the simple decision path; a decision is answered fail-closed regardless. |
Entity reads (Get*, List*, ObjectIdentifiers, rule reads, EvaluateRule, Simulate*, ValidateRule) | Require an authenticated principal (they are admin/config reads and tooling). Account-scoped reads (ListPrincipals, ListAccounts, GetGrant, ListGrants) additionally resolve read visibility against the caller's admin authority. |
Schema mutations (object types, permissions, principals, roles, groups, accounts, rules, templates definitions, Import, Export) | Require system-admin authority (system:*). |
Account-scoped mutations (grants, memberships, BulkPutGrants/BulkDeleteGrants, ApplyTemplate) | Require account-admin authority in the target account (a system-admin supersedes and may drive any account). |
Delegation (Bestow, Revoke) and Impersonation (ImpersonationStart/Stop) | Not routed through the admin gate; each carries its own finer-grained authorization (the delegation subset rule / the impersonation guardrails), where the actor is the delegator / operator, not an admin. |
The actor is always the authenticated principal. For any mutation, the
principal a change is attributed to and authorized against is the identity the
middleware resolved from the request — never a value taken from the request body.
The wire Actor.account field is honoured (it selects the active account), but a
caller cannot act as someone else by editing the body.
The two administrative tiers themselves are ordinary in-scheme authority
(documented in authz/): SYSTEM authority is a holder of system:* (or broader);
ACCOUNT authority is a holder of account:<acct>/admin:* within one account, and
is confined to that account. System supersedes account for account-tier
mutations.
Errors and status codes
Every failure is an APERTURE_* coded error. The handler maps the code onto a
Twirp error code (and thus an HTTP status) and attaches the canonical code as
meta["code"] so a client can dispatch without parsing the message:
| Aperture code (examples) | Twirp code | HTTP |
|---|---|---|
APERTURE_INVALID_INPUT, APERTURE_RULE_INVALID, APERTURE_RULE_UNDECLARED_ATTRIBUTE, APERTURE_TEMPLATE_PARAM, … | invalid_argument | 400 |
APERTURE_UNAUTHENTICATED, APERTURE_INVALID_TOKEN | unauthenticated | 401 |
APERTURE_AUTHZ_DENIED, APERTURE_DELEGATION_DENIED, APERTURE_IMPERSONATION_DENIED, … | permission_denied | 403 |
APERTURE_NOT_FOUND, APERTURE_RULE_NOT_FOUND, APERTURE_PROVIDER_UNREGISTERED | not_found | 404 |
APERTURE_ENTITY_UNMANAGED, APERTURE_STORAGE_CONSTRAINT | failed_precondition | 412 |
APERTURE_UNIMPLEMENTED | unimplemented | 501 |
| anything else | internal | 500 |
See Error Codes for the full registry.
APERTURE_RULE_UNDECLARED_ATTRIBUTE sits with the other rule-authoring codes at
400 rather than in the internal default, because the fault is in the submitted
rule — it names an attribute key the deployment's wiring does not declare — so
no retry of the identical request can succeed, and the rule editor renders it on
the canvas beside the structural and type errors.
Why those two are 412 and not 500
Both are mapped out of the internal default on purpose, and the argument is
the same in each case: nothing is broken, so nothing should page.
APERTURE_ENTITY_UNMANAGED means the deployment does not manage the entity kind
the write targeted — a switch an operator set, working exactly as configured.
APERTURE_STORAGE_CONSTRAINT means the storage layer refused a write that would
have broken referential integrity, overwhelmingly a delete whose children are
still there: a role a principal still holds, a permission a role still bundles,
an account with live memberships or grants. The caller is usually a fully
privileged admin who took the teardown steps out of order. A 500 would page an
on-call for a mistake the caller made and can fix, and would invite the one thing
that can never work — retrying the identical request. A 400 would be closer but
still wrong: the request is valid in isolation and becomes legal the moment the
children are gone, so the fault is in the deployment's state, not in the
message.
412 says exactly that, and the canonical code still rides in meta["code"], so a
client dispatching on the code can look up the fixups — which enumerate the
children to remove first. See
Error Codes and the
troubleshooting table.
A first call
An anonymous decision needs no credential:
curl -s -X POST http://localhost:8080/twirp/aperture.ApertureService/Check \
-H 'Content-Type: application/json' \
-d '{"account":"acme","principal":"alice","action":"read","object":"doc:42"}'
{ "allow": true, "reason": "grant g-123 allows read", "deciding_grant_ids": ["g-123"] }
A mutation needs a bearer token that resolves to a principal with the required tier — here, a system-admin creating an object type:
curl -s -X POST http://localhost:8080/twirp/aperture.ApertureService/PutObjectType \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer root' \
-d '{"actor":{"account":"acme"},"entity_json":"{\"ID\":\"doc\",\"Actions\":[\"read\",\"write\"]}"}'
{}
Related
- RPC reference — the endpoint catalog by area.
- The service facade — the single code path every surface shares.
serve— running the server;--addr,--auth,--store.- Error Codes — the
APERTURE_*registry.
RPC reference
Audience: engineers wiring specific calls against aperture serve.
This is a hand-authored catalog of the ApertureService RPCs, grouped by area.
It summarises each method's purpose, its request/response messages, and its auth
requirement. The canonical, exact field lists live in
internal/wire/rpc/service.proto
— read it for message shapes; this page is maintained by hand and may lag the
proto (the proto wins on any discrepancy).
Every method is a POST to
/twirp/aperture.ApertureService/<Method> with a JSON body. See the
overview for transport, auth, and error mapping. Auth
shorthand used below:
- open — no authenticated principal required.
- auth — requires an authenticated principal.
- system — requires system-admin authority (
system:*). - account — requires account-admin authority in the target account (system supersedes).
- own rule — not gated by the admin tiers; carries its own delegation / impersonation authorization.
Wire-shape convention
Simple hot-path messages (Check, Enumerate) carry their fields directly.
Rich or recursive shapes — the model entities with their timestamps, the
Explain trace, the rule AST — ride as a canonical JSON string in a *_json
field rather than being modelled in proto. That JSON is identical to the
library's own encoding of the corresponding model.* struct. So an
EntityRequest.entity_json is just the JSON of a model.ObjectType,
model.Grant, etc., and an EntityResponse.entity_json is the same on the way
back.
Mutations carry an Actor { principal, account }. On the wire the principal is
ignored — the authenticated identity from the middleware is always used —
while account selects the active account. Reads that are account-scoped resolve
their authority from the authenticated principal directly.
Decision RPCs (open)
The core decision API, single and bulk. These are open (no principal required) and always answer fail-closed.
| RPC | Request → Response | Purpose |
|---|---|---|
Check | CheckRequest → Decision | Is principal allowed action on object in account? Returns allow, a reason, and the deciding grant ids. |
CheckBatch | CheckBatchRequest → CheckBatchResponse | Many Checks in one call; results are index-aligned, each either a Decision or a per-item error code+message. |
Enumerate | EnumerateRequest → EnumerateResponse | Which object ids matching pattern may principal take action on? Optional fields (an object-metadata filter, below), optional references (reference edges, below) and limit. |
EnumerateBatch | EnumerateBatchRequest → EnumerateBatchResponse | Batched Enumerate; index-aligned results. Each query embeds an EnumerateRequest, so fields and references are per-query. |
Search | SearchRequest → SearchResponse | Which of the objects Enumerate would return are the ones a person means by query? Ranked best first, each match carrying its score, the field and value that matched, and the object's full metadata. Optional match_fields, fields, references, min_score, limit. |
SearchBatch | SearchBatchRequest → SearchBatchResponse | Batched Search; index-aligned results. Each query embeds a SearchRequest, so every option is per-query. |
Explain | CheckRequest → ExplainResponse | The full decision derivation for a query, as trace_json (the recursive engine Trace, not modelled in proto). |
ExplainBatch | CheckBatchRequest → ExplainBatchResponse | Batched Explain; index-aligned trace_json or per-item error. |
curl -s -X POST http://localhost:8080/twirp/aperture.ApertureService/Enumerate \
-H 'Content-Type: application/json' \
-d '{"account":"acme","principal":"alice","action":"read","pattern":"doc:*","limit":50}'
{ "object_ids": ["doc:42", "doc:77"] }
SearchRequest — resolving a name to an id
Search is the call to make when a question arrives in a person's own words and
every step after it needs ids.
curl -s -X POST http://localhost:8080/twirp/aperture.ApertureService/Search \
-H 'Content-Type: application/json' \
-d '{"account":"acme","principal":"alice","action":"read",
"pattern":"account:acme/brand:*","query":"nike","limit":5}'
{ "matches": [
{ "object": "account:acme/brand:42", "score": 0.875,
"field": "label", "value": "Nike, Inc.",
"metadata": { "label": "Nike, Inc.", "sector": "Footwear" } }
] }
The result is already scoped — do not filter it client-side. Candidates are
decided before they are scored, by the same walk Enumerate uses, so the
matches are always a subset of what Enumerate would return for the same
account, principal, action and pattern. The alternative shape — enumerate
a type over the wire, then match in your client — puts the unscoped set across
the boundary first and narrows it second, and any bug in that client-side filter
turns your surface into an enumeration oracle for every object in the system.
A score ranks; it never authorizes. Nothing in Aperture reads one. A client
that acts on a returned id still calls Check.
| Field | Meaning |
|---|---|
query | The free text. Required — an empty query is invalid_argument (APERTURE_INVALID_INPUT), not "match everything". For the whole entitled set, call Enumerate. |
match_fields | Metadata field names to restrict matching to. Omit to search every field holding text. Aperture has no notion of a "label" — a label is an ordinary field whose name you chose. |
fields | The object-metadata filter, identical in meaning and encoding to EnumerateRequest.fields (below). It composes: the predicate narrows, the query ranks. |
references | Reference edges, identical to EnumerateRequest.references (below), with the same fail-closed rules. |
min_score | The score floor in [0,1]; <= 0 means the engine default. Raising it narrows the shortlist and never widens what the subject may see. |
limit | Caps the returned matches — the top of a finished ranking, not a bound on the scan, so you get the best N rather than the first N found. |
Matching is case- and punctuation-insensitive (Nike, Inc. matches nike inc)
and tolerates a typo or transposition. Only text is matched — a string field and
the string elements of a list field; match a number, bool or date through
fields instead.
Each match's metadata is the object's full bag, encoded exactly like
EnumerateRequest.fields and carrying the same caveat: a
google.protobuf.Value holds a number as a double, so an integer beyond 2^53
loses precision in transit. It rides along so labelling a result set costs no
further round-trip.
Search requires the deployment to have an object-metadata source wired.
Without one the answer is APERTURE_PROVIDER_UNREGISTERED, never an empty
matches list — an empty list reads as "you may see nothing" and would hide the
misconfiguration.
EnumerateRequest.fields — the object-metadata filter
EnumerateRequest carries an optional fields map (field 6,
map<string, google.protobuf.Value>) that narrows the result by object
metadata. Omitting it filters nothing, so an existing client is unaffected.
curl -s -X POST http://localhost:8080/twirp/aperture.ApertureService/Enumerate \
-H 'Content-Type: application/json' \
-d '{"account":"acme","principal":"alice","action":"read","pattern":"account:acme/**",
"fields":{"tier":"premium","seats":5,"brands":"brand:Y"},"limit":50}'
Semantics — the same contract a provider's own
Filter.Fields obeys,
evaluated by the same code, so a filtered enumeration and a provider-side query
select the same objects:
| Rule | Meaning |
|---|---|
| AND across keys | Every predicate must hold. |
| Membership on collections | A list-valued metadata field matches when it contains the wanted value ("brands": "brand:Y"). A list-valued want is a container compared by equality. |
| Absent never matches | An object whose metadata lacks the key is excluded — not even a null want matches it. |
| Typed comparison | Numbers compare across numeric types by value (5 matches an int64 5), but the string "5" never matches the number 5, and vice versa. |
Filter before limit | Candidates are decided, then filtered, then truncated. Truncating first would return the matches among the first limit candidates rather than the first limit matches — a silently wrong answer. |
| Filter only subtracts | The predicate runs on candidates that already survived deny-overrides, so it can never surface an object Check would deny. |
Why Value and not a JSON string. The predicate compares by type, so the
string/number/bool/list distinction has to be visible in the schema — a JSON blob
in a string field would be opaque to every generated client and unvalidatable
at the boundary. Each Value kind maps to exactly one shape, recursively, with
no coercion between kinds: null→absent-ish nil, number→float64,
string→string, bool→bool, list→array, struct→object.
Caveat: integers beyond 2^53.
google.protobuf.Valuecarries every number as a double, so an integer larger than 2^53 loses precision in transit. Send such a key as a string and store it as a string in metadata. Nothing in the conversion can repair the loss — it is a property ofValueitself. (internal/wire/rpc'sTestFieldsRoundTrip_LargeIntegerLosesPrecisionpins this, and fails loudly if it ever stops holding.)
Rejections are APERTURE_INVALID_INPUT / 400, never a dropped predicate — a
dropped predicate widens the result, and a filter that silently widens is a
filter that authorizes:
- a non-finite number (NaN, ±Inf). It would otherwise become the string
"NaN", an unsatisfiable predicate that reads as "no access". Note protojson cannot marshal a non-finiteValueat all, so only the binary codec can reach this guard; - a
Valuewith no kind set (unreachable from a generated client, reachable from a hand-rolled one).
In EnumerateBatch a malformed predicate fails only its own item — that
item carries the error code/message and never an empty object_ids, and the rest
of the batch runs.
Wiring: the server must have an object-metadata source (the same provider
registry that backs object listing). Filtering a type with no registered
provider, or with no source configured at all, is
APERTURE_PROVIDER_UNREGISTERED / 404 rather than an empty list — an empty list
would read as "no access" and hide the misconfiguration. Because the predicate
runs per candidate, that error only appears when the enumeration has at least one
allowed candidate. An object the provider has no row for is simply excluded
(every field absent, and absent never matches).
EnumerateRequest.references — the reference edges
EnumerateRequest also carries an optional references list (field 7,
repeated ReferenceEdge) that restricts the result to the identities a holder
object's declared reference field contains — "the brands in dataset X".
Omitting it restricts nothing, so an existing client is unaffected.
EnumerateBatchRequest embeds EnumerateRequest, so edges ride per query.
message ReferenceEdge {
string holder_type = 1; // OPTIONAL; empty means holder_id's terminal segment type
string holder_id = 2; // required — "account:acme/dataset:7"
string field = 3; // required — a DECLARED reference field
}
curl -s -X POST http://localhost:8080/twirp/aperture.ApertureService/Enumerate \
-H 'Content-Type: application/json' \
-d '{"account":"acme","principal":"alice","action":"read","pattern":"account:acme/brand:*",
"references":[{"holder_id":"account:acme/dataset:x","field":"current_brands"}]}'
It is a dereference, not a filter, and the two are not interchangeable.
fields answers "which datasets contain brand Y?" — the dataset holds the field,
so a predicate on dataset expresses it. references answers the mirror image,
"which brands belong to dataset X?" — a brand holds no field naming its
datasets (references are declared on the holding side
only), so no predicate on brand can
express it at all.
| Rule | Meaning |
|---|---|
| Several edges AND | "the brands in dataset X and in campaign Z". |
Composes with fields | Both apply; they are independent. |
Before limit | Restriction → decision → filter → truncate. |
| Only subtracts | Edges apply to candidates that already survived deny-overrides, so no edge can surface an object Check would deny. |
| Exactly one hop | The identities an edge yields are never themselves dereferenced. |
holder_type is optional | Empty means "whatever holder_id's terminal segment type is". When given it must agree with holder_id. |
The security semantics are the point, and they are asserted over a real JSON round trip — a boundary that turned an empty result into a 404, or a 404 into an empty list, would silently change what the server discloses about objects it never let the caller see:
| Situation | Response |
|---|---|
| the principal may not read the holder | HTTP 200 with an empty object_ids — never 403, never 404. "You may not see dataset X" and "dataset X contains nothing you may see" must be indistinguishable, or the edge is an oracle for objects the caller was never allowed to know about. |
the holder is absent, inside account, caller is a member | APERTURE_NOT_FOUND / 404 — the ergonomics a typo deserves, confined to a caller already inside the account. |
the holder is outside account | empty, whether or not it exists. This is the disclosure boundary: a caller in one account never learns what does or does not exist in another. |
| the caller is not a member | empty, always — membership is decided before the holder is looked up, so a non-member never sees a 404. |
| a referenced identity no longer exists | skipped, with an operator-side warning log. An application-level foreign key has no database constraint behind it, so one deleted brand must not fail every decision on the dataset that still lists it. |
the field is not a declared reference | APERTURE_PROVIDER_REFERENCE_INVALID / 400 — loud, never an empty list. A wiring fault describes the deployment, not the data; an empty list would read as "no access" to a client that cannot see the deployment. |
the holder_type has no registered provider | APERTURE_PROVIDER_UNREGISTERED / 404 — likewise loud. |
a malformed edge (empty holder_id or field, an unparseable identity, a holder_type that disagrees) | APERTURE_INVALID_INPUT / 400, before any storage or provider work. |
APERTURE_PROVIDER_REFERENCE_INVALIDis a 400, not the 500 the default mapping would give it: the only way a client reaches it is an edge naming an undeclared field — its own input, which no retry can fix — and 5xx is the one class a client is entitled to retry and an alert rule is entitled to page on. The Aperture code is inmeta["code"]either way.
In EnumerateBatch each item keeps its own answer: one query's 404 never becomes
another's, and a fail-closed empty result is reported as an empty list rather
than an error.
See Object references for the
declaration side, and the CLI's --via
for the same query from a terminal.
Entity CRUD
Full create/read/list/delete for each model entity. The write body is
entity_json (a model.* struct as JSON); reads return entity_json (single)
or entities_json (list). List RPCs accept an optional server-side Filter
(field predicates ANDed or ORed) applied before the response is returned.
Writes are system-tier (managing the global schema); reads require auth.
| Entity | Put (system) | Get (auth) | List (auth) | Delete (system) |
|---|---|---|---|---|
| Object type | PutObjectType | GetObjectType | ListObjectTypes | DeleteObjectType |
| Permission | PutPermission | GetPermission | ListPermissions | DeletePermission |
| Principal | PutPrincipal | GetPrincipal | ListPrincipals¹ | DeletePrincipal |
| Role | PutRole | GetRole | ListRoles | DeleteRole |
| Group | PutGroup | GetGroup | ListGroups | DeleteGroup |
| Account | PutAccount | GetAccount | ListAccounts¹ | DeleteAccount |
¹ ListPrincipals and ListAccounts resolve read visibility against the
caller's admin authority, so an account-admin sees only what their tier permits.
Requests: PutX uses EntityRequest { actor, entity_json }; GetX/DeleteX
use GetRequest/DeleteRequest { actor, id }; ListX uses
ListRequest { filter }. Responses: EntityResponse / EntityListResponse /
Empty.
ObjectIdentifiers (ObjectIdentifiersRequest → ObjectIdentifiersResponse,
auth) enumerates every instance id of an object type from its provider,
optionally minus an exclude list — an admin/config read over all objects of a
type.
curl -s -X POST http://localhost:8080/twirp/aperture.ApertureService/PutGrant \
-H 'Content-Type: application/json' -H 'Authorization: Bearer acme-admin' \
-d '{"actor":{"account":"acme"},"entity_json":"{\"ID\":\"g-1\",\"Account\":\"acme\",\"Principal\":\"alice\",\"Action\":\"read\",\"Object\":\"doc:*\",\"Effect\":\"allow\"}"}'
Grants and memberships (account-tier)
Grants and memberships are account-scoped: writes require account-admin in the target account; reads resolve against the caller's authority.
| RPC | Request → Response | Auth | Purpose |
|---|---|---|---|
PutGrant | EntityRequest → Empty | account | Create/replace one grant (model.Grant as entity_json). |
GetGrant | GetRequest → EntityResponse | auth | Read one grant, visibility-scoped to the caller. |
ListGrants | ListGrantsRequest → EntityListResponse | auth | Grants in account_id, with optional Filter. |
DeleteGrant | DeleteRequest → Empty | account | Delete one grant by id. |
PutMembership | EntityRequest → Empty | account | Add/replace a principal's membership in an account. |
DeleteMembership | MembershipKeyRequest → Empty | account | Remove a membership by (principal_id, account_id). |
Rules (definition writes system; reads auth)
Rules are global schema: named, persisted rule-AST definitions the node editor
authors and the rule-backed scope strategies resolve. The AST rides as a
model.Rule JSON in rule_json.
| RPC | Request → Response | Auth | Purpose |
|---|---|---|---|
PutRule | RuleRequest → Empty | system | Persist a rule definition. |
GetRule | GetRequest → RuleResponse | auth | Read one rule as rule_json. |
ListRules | Empty → RuleListResponse | auth | Every stored rule, each as JSON. |
DeleteRule | DeleteRequest → Empty | system | Delete a rule by id. |
ValidateRule | RuleRequest → Empty | auth | Compile/validate a rule AST without persisting. Returns Empty on success, an APERTURE_RULE_* coded error (for the canvas) on failure. Touches no storage. |
What-if / simulation (auth)
Read-only previews. Nothing is written and nothing is audited.
| RPC | Request → Response | Purpose |
|---|---|---|
Simulate | SimulateRequest → Decision | The decision a query WOULD get under a hypothetical overlay (unsaved rules + synthetic grants/permissions/principals layered over the live model). Backs the rule editor's live preview. |
SimulateExplain | SimulateRequest → ExplainResponse | Same overlay, returning the full Explain trace. |
EvaluateRule | EvaluateRuleRequest → EvaluateRuleResponse | Run an UNSAVED rule AST directly against one object's provider metadata (no account/principal/grant); returns the boolean result, the object metadata snapshot the rule saw (object_json), the reference instant it resolved relative dates against (now), what each relative-date operand became at that instant (bounds_json), and the evaluation's deny-safe notes (notes_json). |
Templates
Reusable grant bundles. Definition writes are system-tier; apply is account-tier (it materialises grants into a target account).
| RPC | Request → Response | Auth | Purpose |
|---|---|---|---|
PutTemplate | EntityRequest → Empty | system | Persist a template definition. |
GetTemplate | TemplateKeyRequest → EntityResponse | auth | Read template (name, version); version <= 0 selects the latest. |
ListTemplates | ListRequest → EntityListResponse | auth | List templates, with optional Filter. |
DeleteTemplate | TemplateKeyRequest → Empty | system | Delete by name/version (version <= 0 deletes all versions). |
ApplyTemplate | ApplyTemplateRequest → EntityListResponse | account | Expand a template transactionally into account, filling params and optionally prefixing generated grant ids; returns the applied grants as JSON. |
Bulk grant / revoke (account-tier)
Transactional multi-grant mutations, both account-tier.
| RPC | Request → Response | Purpose |
|---|---|---|
BulkPutGrants | BulkGrantsRequest → Empty | Create/replace many grants (grants_json) in one transaction. |
BulkDeleteGrants | BulkDeleteGrantsRequest → Empty | Delete many grants by id in one transaction. |
Declarative state (system-tier)
Whole-model portability.
| RPC | Request → Response | Purpose |
|---|---|---|
Export | ExportRequest → ExportResponse | Serialize the entire model to one declarative state file as document_json (a system-tier read). |
Import | ImportRequest → Empty | Apply a state file (document_json) as an idempotent, transactional upsert (the most privileged mutation; system-tier). |
Audit query (gated read)
| RPC | Request → Response | Purpose |
|---|---|---|
QueryAudit | QueryAuditRequest → QueryAuditResponse | The append-only audit events matching a filter, newest first. Records nothing. A system-admin may query the whole trail; an account-admin must set account to their own account (which also gates the read). Filters: filter_actor, account, event_type (mutation/decision/impersonation/delegation), outcome (allow/deny/success/failure), since/until (RFC3339), limit. |
Deployment posture and health
| RPC | Request → Response | Purpose |
|---|---|---|
Capabilities | Empty → CapabilitiesResponse | Which entity kinds this deployment manages (manage_accounts / manage_principals / manage_memberships), so a client can render a locked control as disabled instead of failing a save. OPEN — deliberately unauthenticated, because it is three booleans of immutable boot-time configuration and nothing else. |
WiringPosture | WiringPostureRequest → WiringPostureResponse | Whether this instance's background re-read of the shared wiring is failing — so it is still deciding from the last wiring it successfully read — and for how long. System-admin. |
WiringPosture is the deliberate counterpart to Capabilities: the same kind of
question, the opposite answer about who may ask. It is gated, and not a field on
CapabilitiesResponse, because it carries mutable runtime state about a FAULT
whose useful half is a duration — and because "this instance has been enforcing
configuration its operator already replaced, for four hours" tells an anonymous
caller that the enforced policy is not the intended policy, and how long the
window has been open.
It takes an Actor rather than Empty for the reason every other system-tier
call does: system-admin authority resolves in the caller's active account, and
only the caller knows which of its accounts that is. The principal on the wire is
ignored as always.
| Field | Is |
|---|---|
polling / poll_interval | whether this instance re-reads at all, and how often (a Go duration, "30s"). Both empty for a boot-only instance, which cannot be stale in this sense because it never looks again. |
stale | the most recent refresh attempt FAILED. It does not say the deployed wiring changed — a failed read cannot know, which is exactly why the instance keeps what it has and keeps deciding. |
stale_for / stale_since | how long the current run of failures has lasted (Go duration) and when it began (RFC3339). stale_for is the field to alert on. |
failures | consecutive failures in the current run. |
code / reason | the most recent failure's own APERTURE_* code and message — the store's APERTURE_STORAGE_SCHEMA_INCOMPATIBLE, APERTURE_WIRING_CONNECTION_UNROUTED, and only APERTURE_WIRING_REFRESH_FAILED when nothing beneath it was coded. |
digest / last_refresh | the digest of the SHARED wiring this instance is DECIDING FROM (the last-good set, when stale), and when a refresh last succeeded (RFC3339). It covers the five shared tables and nothing else, which is what keeps it comparable with what a push wrote and with what aperture wiring diff reports. |
local_digest | the 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 the state of every instance wired only from the shared tables. It is a separate field and never mixed into digest, and it exists because a rebuild re-reads that document: without it two instances could report the identical digest and still return different verdicts. It moves with an ADOPTION exactly as digest does, so it names the document the RUNNING version was built from and not whatever is on disk now. |
Sweep both. Equal digest across a fleet means the same push landed; equal
digest and equal local_digest means the two instances were built from the
same configuration. Neither field carries a path, an account, a principal or an
object — both are hashes.
Every field is empty or false on a healthy instance, so "nothing is wrong" needs
no interpretation. An instance that does not poll — and a server whose facade was
built without a recorder — answers with polling: false, stale: false rather
than refusing, so the read is usable as a fleet-wide probe. Nothing here carries
model data: digests, durations, counts and coded errors only.
Delegation (own rule)
Not admin-gated; authorized by the delegation subset rule, with the actor = the authenticated delegator.
| RPC | Request → Response | Purpose |
|---|---|---|
Bestow | BestowRequest → Empty | Hand on a subset of the delegator's own grants (grant_json). |
Revoke | RevokeRequest → Empty | Revoke a previously bestowed grant by id. |
Impersonation (own rule)
Not admin-gated; authorized by the impersonation guardrails, with the actor = the authenticated operator. Sessions are stateless, time-boxed values.
| RPC | Request → Response | Purpose |
|---|---|---|
ImpersonationStart | ImpersonationStartRequest → ImpersonationSession | Begin impersonating target in account under mode (augment or become); returns the session with started_at / expires_at (RFC3339). |
ImpersonationStop | ImpersonationStopRequest → Empty | Discard a session (client-side; echoed for symmetry/audit). |
Related
- RPC / HTTP overview — transport, auth model, error mapping.
service.proto— the canonical contract.- The service facade — the shared code path.
- Error Codes — the
APERTURE_*registry.
MCP surface
Audience: authors integrating Aperture into an MCP client (an AI assistant or agent runtime) as a tool provider.
Aperture exposes its decision API and model inspection as
Model Context Protocol tools. An MCP client
spawns aperture mcp as a subprocess, speaks MCP over stdio, and calls the
Aperture tools the same way it calls any other tool server.
Read-only by construction
The MCP surface is read-only. Every tool calls a read or decision method
on the single service.Service facade — Check / Enumerate / Explain,
the what-if Simulate, and the Get* / List* inspectors. No tool mutates.
No tool name carries a mutating verb (put / create / add / set /
delete / remove / update / write / bestow / revoke / grant / …),
and a test (mcp.TestNoMutatingTool, mirrored at the wire level in the adapter)
fails if one ever does. The aperture mcp command deliberately wires the facade
with storage for inspection and what-if reads but not the gate, delegation,
or impersonation mutators, so the surface cannot write even by accident.
Everything on the surface is a thin translation onto the same facade the CLI and the HTTP/Twirp surface drive — one decision engine, one code path. If a behaviour is not described here it is governed by the facade and documented under The service facade.
The tool catalog
The catalog is stable and ordered. Tool names are aperture_-prefixed,
snake_cased, and defined once in mcp/toolmeta (the single source of truth for
tool identity), so the SDK-free core and the SDK adapter never drift.
Decision API (single + bulk)
| Tool | Purpose | Maps to |
|---|---|---|
aperture_check | Decide whether a principal may take an action on an object, scoped to an account. Returns the verdict (allow/deny), a human-readable reason, and the deciding grant ids. Fail-closed: an operational failure renders as a deny, not an error; only an ill-formed question is an error. | service.Check |
aperture_check_batch | Decide many (account, principal, action, object) questions in one round-trip; results[i] answers queries[i]. A single ill-formed query carries its error in that item without failing the batch. | service.CheckBatch |
aperture_enumerate | List the object ids under a pattern a principal may act on — the inverse of aperture_check. Deny-overrides and specificity are honoured, so a denied object is never returned. Takes an optional Fields metadata filter and optional References edges (both below). | service.Enumerate |
aperture_enumerate_batch | Enumerate accessible objects for many queries in one round-trip, aligned with the input queries. Each query carries its own Fields and References. | service.EnumerateBatch |
aperture_search | Resolve a name to an object id: rank the objects a principal may act on by how well their metadata matches free text. Candidates are decided before they are scored, so the result is always a subset of aperture_enumerate's for the same subject, action and pattern. Matching tolerates case, punctuation and a typo. A score ranks; it never authorizes. | service.Search |
aperture_search_batch | Resolve many names in one round-trip, aligned with the input queries — for a sentence that names several things. | service.SearchBatch |
aperture_explain | Return the full structured decision trace for one question: the expanded subject set, every grant considered with its per-grant outcome, which grants decided, and the final verdict. Use to understand why. | service.Explain |
aperture_explain_batch | Return decision traces for many questions in one round-trip, aligned with the input queries. | service.ExplainBatch |
What a trace discloses about attributes
ExplainOut — and SimulateOut — are type aliases for engine.Trace, so
every one of these tools serializes the trace verbatim. That includes its
Attributes field: the principal and account bags the decision's rules were
evaluated against, values included.
That is a deliberate disclosure, and it reached this surface with no mcp/ code
change at all — which is exactly why it is written down here. The two bags are
the subjects of the very request being explained (the principal and account named
in the query), so a trace tells the asker about their own decision and nothing
else; see the attribute bags.
Gate the MCP surface accordingly: an agent that may call aperture_explain on an
arbitrary (account, principal) pair can read that principal's attribute bag for
that account.
There is deliberately no attribute tool. Listing a slot returns the host's
whole user table, and MCP is where an agent doing that is least defensible, so
the directory read stays a system-tier CLI operation
(aperture attributes) and never becomes a tool.
aperture_enumerate's Fields filter
Fields is an optional object of metadata predicates that narrows the
listing — "which of the datasets alice may list carry brand Y?". Omit it to
filter nothing; it is not a required property, so an unfiltered enumerate is a
valid call for a schema-validating client.
{
"Account": "acme", "Principal": "alice", "Action": "list",
"Pattern": "account:acme/**",
"Fields": { "tier": "premium", "seats": 5, "brands": "brand:Y" }
}
The predicates are ANDed; a field the object does not carry never
matches; a list-valued field matches by membership; everything else is
typed equality, so "5" never matches 5. The filter runs on objects the
principal is already allowed — it can only remove ids, never add one — and it is
applied before Limit, so Limit: 10 returns the first ten matches.
The schema is reflected straight off service.EnumerateQuery, so the tool input
and the Go facade query are the same type; the semantics above are carried in the
property description an agent reads.
aperture_enumerate's References edges
References is an optional list of reference edges that restricts the
listing to the identities a holder object's declared reference field
contains — "which brands belong to dataset X?". Omit it to restrict nothing; like
Fields, it is not a required property.
{
"Account": "acme", "Principal": "alice", "Action": "read",
"Pattern": "account:acme/brand:*",
"References": [{ "HolderID": "account:acme/dataset:x", "Field": "current_brands" }]
}
HolderID and Field are required properties of an edge; HolderType is
optional and, when given, must agree with HolderID's last segment type.
This is not the same thing as Fields, and an agent that treats them as
interchangeable will get the wrong answer. Fields is a filter — "which
datasets contain brand Y?" — and works because the dataset holds the field.
References is a dereference — "which brands belong to dataset X?" — and
exists because a brand holds no field naming its datasets, so no filter can
express that question at all.
Several edges are ANDed, an edge composes with Fields, both apply before
Limit, and exactly one hop is taken.
The answers an agent must not over-read:
- A holder the principal may not see returns an empty list, not an error. Emptiness here means "nothing you may see", and it is deliberately indistinguishable from "you may not see the holder" — do not report it as a permission failure.
- An absent holder is
APERTURE_NOT_FOUNDonly when it is inside the request's account and the principal is a member of it. Out of account, or for a non-member, the answer is empty. - An edge naming a field that is not a declared reference is a loud
APERTURE_PROVIDER_REFERENCE_INVALID, never an empty list — it means the deployment is not wired for that question, not that access was denied.
What-if simulation (read-only, never persisted)
| Tool | Purpose | Maps to |
|---|---|---|
aperture_simulate | Render the full decision trace for a question as it would be under a hypothetical overlay of principals, groups, permissions, grants, and memberships — without writing anything. The overlay is additive; an overlay entity with the same id as a stored one shadows it. Nothing is persisted and nothing is audited. | service.SimulateExplain |
Model inspection
| Tool | Purpose | Maps to |
|---|---|---|
aperture_list_object_types | List every object type with its declared action verb set. | service.ListObjectTypes |
aperture_get_object_type | Fetch one object type by name. | service.GetObjectType |
aperture_list_permissions | List every permission (object-type, action, scope-strategy, delegatable flag). | service.ListPermissions |
aperture_get_permission | Fetch one permission by id. | service.GetPermission |
aperture_list_roles | List every role (named permission bundles). | service.ListRoles |
aperture_get_role | Fetch one role by id, including its permission bundle. | service.GetRole |
aperture_list_groups | List every group (collections of principals usable as grant subjects). | service.ListGroups |
aperture_get_group | Fetch one group by id, including member principal ids. | service.GetGroup |
aperture_list_principals | List every principal (user or machine) with assigned role ids and identity strings. | service.ListPrincipals |
aperture_get_principal | Fetch one principal by id, including assigned roles. | service.GetPrincipal |
aperture_list_grants | List every grant stamped to an account. Account-scoped: a grant in another account is never returned. | service.ListGrants |
aperture_get_grant | Fetch one grant by id (subject, permission, object pattern, effect, account). | service.GetGrant |
Surface documentation
| Tool | Purpose | Maps to |
|---|---|---|
aperture_skills_list | List the embedded skill docs describing how the decision, simulate, and inspection tools fit together. | mcp/skills.List |
aperture_skills_get | Fetch the markdown body of a named skill doc (e.g. mcp-surface), the authoritative reference for driving the surface. | mcp/skills.Get |
Grants are account-scoped by design: aperture_list_grants requires an
account argument and never returns another account's grants, so the surface
cannot leak cross-account data.
Name resolution, and the two things an agent must not assume
aperture_search exists because an agent receives questions in a person's words
and needs ids. Two properties are stated in the tool's own description, because
an agent has no other source of truth and both failure modes are silent:
- A score is not a permission. It ranks candidates so the agent can pick one
or ask which was meant. An id still goes through
aperture_checkbefore it is acted on. - The result is already scoped. There is nothing to filter afterwards, and an object missing from the result is one this principal may not see — not a matching failure to work around by enumerating the type and filtering locally. That workaround is precisely the bare-enumeration shape the surface exists to remove.
Query is required; there is no "match everything". For the whole entitled set
the tool is aperture_enumerate. Each match carries the object's full metadata
inline, so an agent should not follow up with per-id metadata reads to label what
it found.
Inputs, outputs, and errors
Each tool carries an input and output JSON Schema (draft 2020-12),
reflected at package-init time from the typed Go contract in mcp/schema.go and
mcp/contract.go. The In/Out types alias the facade's own surface-neutral types
(for example aperture_check's input is service.Query and its output is
service.Result), so the schema advertised to the client is exactly the shape
the facade decides on. A client discovers these schemas through the standard MCP
tools/list call — no Aperture-specific schema fetch is needed.
Argument handling and error surfacing:
- Parameterless tools (the
list_*inspectors,aperture_skills_list) accept an empty argument blob. - A missing required argument (for example
idon aget_*tool) returns a plain validation tool-error, surfaced to the model so it can self-correct — it is intentionally not a coded error. - A coded facade error (an
APERTURE_*error) is returned verbatim; the adapter renders it as a structured{code, message, details}envelope rather than a flattened string. - In the batch tools, a per-item failure is folded into that item's
errorfield (a string); the rest of the batch is unaffected.
The mcp/ core and the SDK firewall
The core lives in the root mcp/ package and is SDK-free: it imports no MCP
protocol SDK. Its files divide the surface cleanly:
| File | Role |
|---|---|
mcp/toolmeta/ | Leaf, pure-data package: the canonical (name, description) table for every tool. Imported by both the core and the adapter so the two never drift. |
contract.go | The typed In/Out structs for every tool (aliasing the facade's service / engine / model types). |
schema.go | Reflects an input + output JSON Schema for each contract type at init, carried as json.RawMessage. |
handlers.go | One typed func(ctx, *service.Service, In) (Out, error) per tool; each calls exactly one facade read/decision method. |
tools.go | Type-erases handlers into a ToolDescriptor catalog (Tools(cfg)), pairing each name with its reflected schemas and an Invoke closure. |
The core depends only on the decision facade (service), the domain types it
returns (engine, model), the coded-errors package (errors), the embedded
skill docs (mcp/skills, stdlib-only), and the schema reflector
(google/jsonschema-go). It carries the schema as json.RawMessage
specifically so a consumer can import the contract without pulling in any MCP
SDK.
The adapter is the only SDK importer
The one package permitted to import the protocol SDK
(github.com/modelcontextprotocol/go-sdk) is the thin mcp/gosdk adapter.
gosdk.Register(server, svc, cfg) mounts the core's ToolDescriptor catalog
onto a caller-supplied go-sdk server via the low-level Server.AddTool path —
which accepts any value that marshals to a valid 2020-12 schema, exactly the
json.RawMessage the core emits. Register mounts onto the server it is given;
it never constructs, serves, or owns the server lifecycle. An embedder that
already runs its own go-sdk server can mount the full Aperture surface the same
way.
firewall_test.go makes the guarantee load-bearing
mcp/firewall_test.go enforces the boundary so it is a fact, not an aspiration:
TestMCPCore_NoSDKImportrunsgo list -depsover the core packages (mcp,mcp/toolmeta,mcp/skills) and fails if the MCP SDK module appears anywhere in their transitive dependency graph. Moving an SDK import into any core package makes this test fail with the exact offending dependency line.TestMCPCore_AllowedDepsReachableasserts the documented allow-list (service,engine,model,errors,mcp/skills,mcp/toolmeta,google/jsonschema-go) is reachable from the core, proving the firewall is inspecting a real, populated graph rather than passing vacuously.
The adapter (mcp/gosdk) is deliberately excluded from the firewall's package
list — the SDK is allowed there and nowhere else. This keeps the promise a client
author relies on: importing the Aperture MCP contract never drags in a protocol
SDK.
Running it
aperture mcp hand-wires the read-only dependency graph
(storage → engine → service), constructs the go-sdk server, mounts the SDK-free
catalog through the gosdk adapter, and serves over stdio — the transport an
MCP client uses when it spawns Aperture as a subprocess:
aperture mcp [--seed <path>] [--store <dsn>]
--seed— path to a JSON/YAML seed model to apply on startup. Omitting it loads the embedded example for the in-memory store and seeds nothing for a--storeDSN (see Global options).--store— DSN for the backing store (defaults to in-memory).
With neither flag it serves the embedded example model over an in-memory store. There are no other flags: the surface is read-only by construction, so it needs no acting principal, account, or auth adapter.
On start it prints one line to stderr — stdout is reserved for the MCP protocol:
aperture mcp: serving read-only MCP surface over stdio
Connecting a client
You normally don't launch aperture mcp interactively; an MCP client spawns it
over stdio. A minimal client configuration points at the binary and the model to
serve:
{
"mcpServers": {
"aperture": {
"command": "bin/aperture",
"args": ["mcp", "--store", "./aperture.db"]
}
}
}
The server advertises its identity as aperture with the binary's build version
during the MCP initialize handshake, then answers tools/list with the full
catalog above and tools/call for each tool.
Related
aperture mcp— the command-level reference and flags.- The service facade — the one code path behind every tool.
- Decision API — the same check/enumerate/explain questions from Go.
- RPC / HTTP overview — the read/write Twirp surface served by
aperture serve.
Admin UI shell
Audience: operators who want to browse and edit the access-control model from a browser instead of the CLI or raw RPC.
Aperture ships a small embedded admin UI — a static, single-page frontend
served from the root of a running aperture serve. It is not a separate service:
the HTML, CSS, and JavaScript are compiled into the binary and mounted at /, so
starting the server is all it takes to reach the UI. Every screen is a thin client
over the same Twirp / HTTP surface; the shell holds no
access-control logic of its own — it decodes what the server returns and posts
mutations back through the facade.
What it is built from
The shell is deliberately dependency-light and builds with no Node pipeline. Its
assets are pre-built, committed blobs embedded via //go:embed all:static (see
internal/server/static.go), so the binary stays self-contained in dev and CI.
| Layer | Role in the shell |
|---|---|
| Alpine.js | Small reactive framework driving each screen's component and the hash-routed navigation. |
| Design tokens | The house design-token system. The shell's authoritative styling is the hand-authored static/css/styles.css, written from the token spec; it is not Tailwind/DaisyUI. |
| Tailwind CSS / DaisyUI | Vendored utility/component CSS carried for the domain screens. |
| Rete.js | A node-graph editing library. The Rules screen mounts a Rete canvas to edit rule blueprints; the bundle is loaded at runtime from /vendor/rete/rete.min.js. |
The full frontend lives under internal/server/static/ (index.html, css/,
js/, and vendor/). Vendored blobs (Alpine, Tailwind, DaisyUI) are pinned and
documented in internal/server/static/vendor/README.md. The Rete.js bundle is
the one asset regenerated by a manual make vendor-rete target — it is never
rebuilt in CI. Treat all of these as committed artifacts, not build outputs.
How to reach it
Start the server and open the listen address in a browser:
aperture serve # listens on :8080 by default
- Default address:
:8080(override with--addr). See theservecommand for all flags. - The UI is mounted last on the mux at
/. More specific routes — the Twirp path prefix,POST /check,GET /healthz— take precedence, so the frontend never shadows the API.
Auth expectation
Aperture's authentication is external — the shell never issues credentials.
Every request the UI makes is routed through a single apiFetch wrapper that
attaches an Authorization: Bearer <token> header, and a 401 clears the token
and re-opens the sign-in affordance.
With the default dev authenticator (aperture serve with no --auth
override), the bearer token is the principal id: "signing in" through the UI
just names which principal your session presents. Switching to the oidc or
parsec adapters changes how that bearer is obtained, but the shell's behaviour
is unchanged — it still only carries whatever token it holds. A global account
switcher in the top bar picks the active account shared by every screen; the
server scopes the account list to what the signed-in principal may see
(ListAccounts).
Screen tour
The left-hand nav routes by URL hash (#/crud, #/grants, …). Each screen is
one Alpine component in static/js/, and each drives a small set of Twirp RPCs
on aperture.ApertureService.
Model (CRUD) — #/crud
The model editor. Browse and mutate the core entities — principals, roles,
groups, permissions, object types, and accounts — plus account memberships. It
lists each entity type (ListPrincipals, ListRoles, ListGroups,
ListPermissions, ListObjectTypes, ListAccounts), creates/updates via the
matching Put*, and removes via Delete*; memberships use
PutMembership / DeleteMembership. It probes the viewer's admin tier with the
open Check RPC and can Export the model.
Grants — #/grants
Bestowed grants, delegation, and grant templates. Lists and edits grants
(ListGrants, PutGrant, DeleteGrant), issues and rescinds authority
(Bestow, Revoke), and manages templates (ListTemplates, PutTemplate,
DeleteTemplate, ApplyTemplate) with a bulk path (BulkPutGrants). It uses
Check to resolve the viewer's tier.
Graph — #/graph
A force-directed map of the whole model rendered on a <canvas>. It pulls the
entire declarative model with a single Export RPC (seeding an account via
ListAccounts first) and derives every node and edge — subjects, grants,
objects, memberships, roles, and reachability — from that one document, so the
picture always reflects the live store. The layout is a small self-contained
simulation; no external graph library is used here.
Rules — #/rules
The rule-blueprint editor. This is where Rete.js is used: rules are edited as
a node graph on a Rete canvas, then serialized to Aperture's rule AST. It loads
and saves rules (ListRules, GetRule, PutRule), lists object types
(ListObjectTypes) and object identifiers (ObjectIdentifiers), and validates
or dry-runs an expression against the live model (ValidateRule,
EvaluateRule, Check).
The what-if preview shows more than the verdict: the reference instant the
evaluation resolved against, what each relative-date operand became at that
instant, and the deny-safe notes explaining a false. Dates are rendered
verbatim, Z included — the editor never constructs a JavaScript Date,
which would restate a stored UTC instant in the viewer's own zone.
What-if — #/whatif
A read-only decision simulator. Ask "would principal P be allowed action A on
object O?" and see both the verdict and the full explain trace — the expanded
subject set, every grant considered with its per-grant outcome, and the deciding
grant(s). It drives the open Check and Explain RPCs (no mutation), listing
accounts via ListAccounts. It runs a hypothetical against the live model
without writing anything.
Audit — #/audit
A queryable table over the append-only audit trail via the QueryAudit RPC.
Every mutation, impersonation, and delegation is always recorded; decisions are
sampled. Read access is tier-gated — a system-admin reads everything, an
account-admin reads only events scoped to their own account — and the screen
probes both tiers with Check (using ListAccounts for scope). A viewer with
neither tier is told why the table is empty.
Import / export (portability) — #/portability
Download the declarative model file or import one with a preview diff. It exports
the whole model (Export), and imports a supplied document (Import) after
resolving the viewer's tier with Check and scoping with ListAccounts.
Related
- RPC / HTTP overview and the RPC reference — the surface every screen calls.
servecommand — flags, listen address, and auth adapter.- The service facade — the one code path behind every RPC the UI drives.
Identity patterns & specificity
Everything Aperture decides is expressed in one primitive: a uniform,
hierarchical identity. The same type describes principals (the actor a
decision is made for) and objects (the resource an action targets), so there
is no second addressing scheme to learn. This chapter defines that primitive,
the pattern grammar a grant uses to cover many identities at once, the
Contains containment test delegation relies on, and the specificity score
that resolves overlapping matches.
If you have not read the Concepts primer, skim
it first — it fixes the vocabulary (principal, object, grant, effect) this page
builds on. The code lives in the identity package.
The identity primitive
An identity is an ordered path of typed segments. Each segment is a
type:id pair; segments are joined by /:
account:acme/project:atlas/document:42
Read left to right, each segment narrows the path: account acme, then project
atlas within it, then document 42 within that. A principal is written the
same way — user:alice or account:acme/team:eng — because principals and
objects share the type.
The grammar is intentionally strict, and matching runs on the Check hot path
(the NFR target is p99 < 1 ms), so parsing validates without regular
expressions and stores segments pre-split so a match never re-parses:
- A segment's type and id are each non-empty.
- Allowed characters in a component are ASCII letters, digits, and
-._~@+— enough for slugs, UUIDs, and qualified ids, while excluding the structural delimiters/and:, whitespace, and the*wildcard sentinel. - An empty string, an empty segment (a leading, trailing, or doubled
/), a segment missing its:, or an illegal character is rejected as anAPERTURE_IDENTITY_INVALIDcoded error.
identity.Parse builds an Identity from its canonical string and round-trips
losslessly (Parse(s).String() == s); identity.New builds one from segments a
caller already holds. The identity package depends on nothing but the root
errors/ package, so it stays a leaf every other layer resolves against.
id, err := identity.Parse("account:acme/project:atlas/document:42")
if err != nil {
// APERTURE_IDENTITY_INVALID
}
id.Len() // 3
id.Segments() // []Segment{{account,acme}, {project,atlas}, {document,42}}
Patterns
A single grant rarely names one object. A pattern is an identity with
wildcards that describes a set of concrete identities; a grant's object scope
is a pattern. Build one with identity.ParsePattern. Beyond the concrete-identity
grammar, a pattern segment may be:
| Form | Meaning |
|---|---|
type:id | a literal segment — matches exactly one segment with that type and id |
type:* / *:id | a component wildcard — * in place of a type or id matches any value for that component |
type:{a,b,c} | a component set — matches any of the listed values (e.g. brand:{1,5,23}) |
* | a single wildcard segment — matches exactly one segment of any type and id |
** | a double wildcard segment — matches one-or-more segments recursively |
A component set lets one grant scope to several ids without a wildcard, so
brand:{1,5,23} covers exactly three brands and nothing else. The set must be
non-empty ({} is rejected); duplicate members are dropped, preserving order.
How a pattern matches
Pattern.Matches(identity) is anchored at both ends: every identity segment
must be consumed and every pattern segment satisfied. A literal or single *
each consume exactly one identity segment; ** consumes one-or-more via
backtracking. The matcher slices into the pre-compiled segments and does not
allocate.
Pattern Matches?
account:acme/project:atlas/** account:acme/project:atlas/document:42 ✓ (** = "document:42")
account:acme/project:atlas/** account:acme/project:atlas ✗ (** needs ≥1 segment)
account:acme/project:*/document:42 account:acme/project:atlas/document:42 ✓ (project id wildcard)
account:acme/brand:{1,5,23} account:acme/brand:5 ✓ (5 ∈ set)
account:acme/brand:{1,5,23} account:acme/brand:9 ✗ (9 ∉ set)
Because ** must consume at least one segment, a pattern is never shorter
than the identities it matches minus its wildcard expansion — a trailing **
matches any non-empty remainder but not the empty one.
Enumerating a finite pattern
When a pattern contains no * or ** anywhere — every segment is a literal
whose components are plain literals or explicit sets — its language is finite.
Pattern.Expand() returns that concrete set as the deterministic cross-product
of each segment's members (segment order, then written member order), reporting
ok=false for any pattern with a wildcard (whose language is unbounded or
provider-dependent). This lets a set-scoped grant like brand:{1,5,23} list its
three concrete objects without consulting a provider.
Specificity: resolving overlaps
Two grants can both match the same object with different effects — a broad
allow on account:acme/project:atlas/** and a narrow deny on
account:acme/project:atlas/document:secret. Aperture resolves this with
deny-overrides ranked by specificity: the more specific pattern wins, and on
a genuine tie deny beats allow. identity.Specificity(pattern) produces the
deterministic score that drives the tiebreak.
The score is a single weighted integer (paths are short, so there is no overflow risk) built from three signals:
| Signal | Weight | Rationale |
|---|---|---|
| literal components (a fixed type or id) | 10000 each | a pinned value is the strongest specificity signal |
| total segment count | 100 each | a longer pinned path breaks ties among equal literal counts |
each ** | −1000 penalty | a recursive wildcard matches a variable-length span, the least specific construct |
A type:id literal contributes up to two literal components; a component
wildcard (*) or a bare * segment contributes none (only its pinned
position). The result is that the pattern a human would call "more specific" —
more fixed components, fewer and shallower wildcards — always scores strictly
higher.
Pattern Specificity
account:acme/project:atlas/document:secret 6 literals·10000 + 3 segs·100 = 60300
account:acme/project:atlas/** 4 literals·10000 + 3 segs·100 − 1000 = 39300
account:acme/** 2 literals·10000 + 2 segs·100 − 1000 = 19200
identity.Compare(a, b) returns +1 / -1 / 0 as a total, deterministic order
(safe as a sort key), and identity.MoreSpecific(a, b) reports whether a
ranks strictly above b. The decision engine consumes these directly; this
package never applies deny-overrides itself — it only supplies the ranking.
flowchart TD
O["object: account:acme/project:atlas/document:secret"]
O --> G1["grant A · allow<br/>account:acme/project:atlas/** · spec 39300"]
O --> G2["grant B · deny<br/>account:acme/…/document:secret · spec 60300"]
G1 --> R{"both match —<br/>rank by specificity"}
G2 --> R
R -->|"B is more specific"| D["deny wins"]
Containment: Contains
Delegation asks a different question: is one grant's authority a subset of
another's? identity.Contains(outer, inner) answers it structurally — it
reports whether outer's language is a superset of inner's, i.e. every
concrete identity inner matches, outer matches too. Equivalently, inner is
"equal-or-more-specific / contained within" outer. outer.Contains(inner) is
the method form.
The test is sound and conservative: it never reports containment that does
not hold, but — because it is a structural check rather than a full language
inclusion decision over the one-or-more ** — it may return false for some
genuinely-contained pairs. Delegation fails closed, so a false negative merely
rejects a bestow that could in principle have been allowed; it never permits an
escalation. It is reflexive (a pattern contains itself).
Contains(account:acme/**, account:acme/project:atlas/document:42) = true
Contains(account:acme/brand:{1,5}, account:acme/brand:5) = true
Contains(account:acme/brand:{1,5}, account:acme/brand:{5,9}) = false (9 ∉ {1,5})
Contains(account:acme/*, account:acme/**) = false (single can't cover ≥1)
For a component, an outer wildcard covers anything, an inner wildcard is never
subsumed by a fixed outer, and otherwise both reduce to finite value sets where
outer must contain every inner value — so {1,5,23} subsumes 5 and {1,5}
but not 3 or {5,9}.
Where this leads
Patterns bound what a grant covers, but membership within that bound can be more than literal matching — a grant can cover "every object of this type" or "these ids only" or "whatever a rule selects." That is the job of scopes and scope strategies. The attributes a rule reads about an object are supplied by the host through providers. For the end-to-end decision flow that consumes specificity, see the library Decision API.
Rules engine
A grant can be gated on the attributes
of a request — deny a read unless the object's classification is public,
allow a share only for principals above a clearance tier. Aperture expresses
those conditions as rules. A rule is a small, typed AST that Aperture
compiles once to an expression program and evaluates in-process against the
object's metadata plus the principal/action context.
Aperture evaluates rules with
expr-lang/exprdirectly. It renders each rule AST to an expr-lang expression and compiles it with expr-lang's pure-Go evaluator, in-process. There is no external policy service and no dependency on Pulse — any documentation that says "Pulse expression" is stale. The rules package importsgithub.com/expr-lang/expr; that is the whole engine.
The code lives in the rules package, in three layers: the AST (ast.go), the
compiler and cache (compiler.go, cache.go), and the engine (engine.go).
The rule AST
A rule is a tree of rules.Node values. The node set is deliberately small
and closed so a node editor can map its palette one-to-one onto it, and so the
JSON form is a stable contract that round-trips byte-identically
(marshal → unmarshal → marshal). There is no second rule format.
NodeType | Fields used | Meaning |
|---|---|---|
and | Children (≥ 2) | logical conjunction |
or | Children (≥ 2) | logical disjunction |
not | Children (exactly 1) | logical negation |
compare | Op, Left, Right | a comparison; Right is omitted for the unary operators |
var | Name (dotted path) | a context-variable reference |
literal | Value (scalar JSON) | a string, number, bool, or null constant |
list | Items | an ordered list, the right side of a collection operator |
call | Name, Items (args) | a call to a registered pure function |
The operators carried in compare.Op are covered in
the operator vocabulary below.
Constructor helpers build the tree in Go — And, Or, Not, Compare,
Unary, Var, Lit, List, Call. This rule says the object is public, or
the principal's tier is one of gold/platinum:
ast := rules.Or(
rules.Compare(rules.OpEq, rules.Var("object.classification"), rules.Lit("public")),
rules.Compare(rules.OpIn, rules.Var("principal.tier"),
rules.List(rules.Lit("gold"), rules.Lit("platinum"))),
)
Its canonical JSON — the shape the editor and the state file persist — omits every zero field, keeping the serialized form minimal:
{
"type": "or",
"children": [
{"type": "compare", "op": "eq",
"left": {"type": "var", "name": "object.classification"},
"right": {"type": "literal", "value": "public"}},
{"type": "compare", "op": "in",
"left": {"type": "var", "name": "principal.tier"},
"right": {"type": "list", "items": [
{"type": "literal", "value": "gold"},
{"type": "literal", "value": "platinum"}]}}
]
}
The evaluation context
A rule reads from a closed set of four roots, and only those — a reference to anything else is an unknown variable:
| Root | Type | Contents |
|---|---|---|
object | map | the object's metadata snapshot (host-defined fields, e.g. object.classification) |
principal | map | the principal's attribute bag (e.g. principal.tier); the floor {id, kind} is always present |
account | map | the active account's attribute bag (e.g. account.plan); the floor {id} is always present |
action | string | the action verb (action == "read") |
The three metadata roots are map[string]any so a rule reads host-defined
fields dynamically; action is a typed string so misusing it (action.foo) is
a type error. This closed environment is enforced twice — structurally by
Validate (below) and again by the expr-lang type-checker at compile time.
The operator vocabulary
There are eleven comparison operators beyond the six scalar ones. All of them
are values of compare.Op — none is editor-only sugar, because the AST is the
editor's serialization target and the state file's persisted form: a rule
authored as has all must read back as has all.
Scalar comparisons
Op | Reads as | Renders to |
|---|---|---|
eq / ne | object.tier == "gold" | == / != |
lt le gt ge | object.level >= 3 | < <= > >= |
Both operands are required and either may be any operand node.
Collection operators
Left is the collection being tested (for exists, any path at all). Right is
the operand — and the last three operators are unary: they reuse the same
compare node with Right omitted, rather than introducing a new node type.
Op | Reads as | Left applies to | Right |
|---|---|---|---|
in | object.region in ["us","eu"] | any | a list or a var |
nin | principal.id not in object.blocklist | any | a list or a var |
has | object.tags has "urgent" | array | one element — never a list |
hasAll | object.tags has all ["a","b"] | array | a list or a var |
hasAny | object.tags has any ["a","b"] | array | a list or a var |
hasNone | object.tags has none ["a","b"] | array | a list or a var |
subsetOf | object.tags subset of ["a","b"] | array | a list or a var |
hasKey | object.owner has key "dept" | object | one element — never a list |
isEmpty | object.tags is empty | array, object | omitted |
isNotEmpty | object.tags is not empty | array, object | omitted |
exists | object.owner.dept exists | any path | omitted |
// "tagged both a and b, in no blocked region, and it actually has an owner"
rules.And(
rules.Compare(rules.OpHasAll, rules.Var("object.tags"),
rules.List(rules.Lit("a"), rules.Lit("b"))),
rules.Compare(rules.OpHasNone, rules.Var("object.regions"),
rules.Var("account.blockedRegions")),
rules.Unary(rules.OpExists, rules.Var("object.owner.dept")),
)
rules.Unary(op, left) builds a unary node; it is still a compare node, and
its JSON simply carries no right key:
{"type":"compare","op":"isEmpty","left":{"type":"var","name":"object.tags"}}
That omission is part of the contract. Validate requires Right == nil for
exactly isEmpty, isNotEmpty and exists and rejects a supplied one, so
there is only ever one spelling of a unary rule and the JSON round-trips
byte-identically. Every other operator still requires both operands.
How each operator compiles
expr.DisableAllBuiltins() is in force, so nothing may be assumed reachable.
Each operator makes a deliberate choice:
| Strategy | Operators | Rendered form |
|---|---|---|
| Native infix | eq ne lt le gt ge in nin | (left <op> right) |
Native, flipped in | has, hasKey | (right in left) |
| Native nil test | exists | (left != nil) |
| Registered pure function | hasAll hasAny hasNone subsetOf isEmpty isNotEmpty | hasAll(left, right), isEmpty(left), … |
| Guarded dispatcher | any collection operator over a non-literal operand | $op("hasAll", __notes, "object.tags", object?.tags, "", ["a"]) |
has and hasKey need no new machinery because expr's in is element
membership over an array and key membership over a map — "dept" in object.owner is already the hasKey semantics, so the operator is pure
spelling. exists leans on the optional chaining
var already renders, which is what makes object.owner.dept exists false
rather than a runtime error when owner is missing.
The six with no native spelling are backed by functions in the curated pure set. Each is deterministic and side-effect-free, so the purity guarantee holds: they read their arguments and nothing else.
The guarded row overrides the other four. A collection operator whose
collection operand is not statically known to be a collection — anything but a
list literal, so in practice any operand that reads metadata — renders to the
internal dispatcher $op instead, which applies the shape
policy and records a note. The
common object.region in ["us","eu"] keeps its native in: a list literal is an
array by construction and cannot mismatch, so the decision path pays nothing for
a guard it does not need.
Neither $op nor the __notes sink it reads is reachable from a rule. $ is
outside the identifier grammar Validate enforces for a call name, and
__notes is not one of the four exposed context roots — so the guard is
compiler-only by construction, with no denylist to keep in sync.
Predicate builtins are denied
expr.DisableAllBuiltins() does not do everything its name suggests. expr's
fifteen predicate builtins — all, any, none, one, filter, map,
count, sum, find, findIndex, findLast, findLastIndex, groupBy,
sortBy, reduce — are resolved by the parser before it consults the disabled
table, so all(...) still compiles under Aperture's option set while len(...)
genuinely does not. Nothing in the AST emits expr's # pointer, so none is
reachable through a well-formed rule today — but a call node renders
name(args…) verbatim, so Call("all", …) would compile. Validate therefore
rejects those names outright with APERTURE_RULE_INVALID, and a test pins the
callable set against expr's own builtin registry.
Missing fields and nested access
Metadata is host-defined and ragged: one object carries an owner, the next does
not. Two behaviors follow from that, and both matter for whether a rule grants.
Nested access is nil-safe
A var may read a nested path — object.owner.dept. Aperture renders every
segment after the root with optional chaining, so that path compiles to
object?.owner?.dept. If owner is absent the read yields nil, the
enclosing comparison goes false, and the rule simply does not select.
This matters because the un-chained form is not nil-safe: in expr-lang,
object.owner.dept against an object with no owner is a runtime error
(cannot fetch dept from <nil>), which would surface as APERTURE_RULE_EVAL at
Check time. A rule that works against every object carrying an owner would
otherwise blow up on the one object that lacks it — in production, not in dev.
Optional chaining makes that uniform and deny-safe, so no rule author has to
write a guard. A path through a present intermediate behaves exactly as
before; ?. differs from . only when the receiver is nil.
// Selects only for objects that actually carry owner.dept == "eng".
// An object with no owner at all evaluates to false, never an error.
rules.Compare(rules.OpEq, rules.Var("object.owner.dept"), rules.Lit("eng"))
⚠️ nin over a field the object lacks grants
in and nin over a missing field do not error — but they are not symmetric in
their consequences:
| Expression | Object has the field | Object lacks the field |
|---|---|---|
x in object.blocklist | membership | false — does not select |
x not in object.blocklist | non-membership | true — selects |
x not in <nil> evaluating to true is the correct logical dual, and it is
expr-lang's pre-existing behavior, not something Aperture introduces. But the
practical effect is a trap: a deny-list rule written with nin passes every
object that is simply missing the column. A typo'd field name, a CSV without
that header, or a nested path through an absent intermediate all read as nil —
and the rule grants.
List-valued and nested metadata make this far easier to hit than it used to be. If a rule must not select for objects lacking the field, require the field explicitly:
rules.And(
rules.Compare(rules.OpNe, rules.Var("object.blocklist"), rules.Lit(nil)),
rules.Compare(rules.OpNin, rules.Var("principal.id"), rules.Var("object.blocklist")),
)
Collection operators: an absent field reads as an empty collection
Every collection operator applies the same rule — an absent array or object is
not an error, it is an empty one. Which way that falls out depends on the
operator's polarity, and the negative operators grant exactly the way nin does:
| Field absent | has | hasAll | hasAny | hasKey | isNotEmpty | exists | hasNone | subsetOf | isEmpty |
|---|---|---|---|---|---|---|---|---|---|
| result | false | false | false | false | false | false | ⚠️ true | ⚠️ true | ⚠️ true |
hasNone and subsetOf are the ones to watch: no forbidden tag and only
allowed tags are both trivially satisfied by an object that carries no tags at
all. Pair them with exists (or isNotEmpty) when the field must be present:
rules.And(
rules.Unary(rules.OpIsNotEmpty, rules.Var("object.tags")),
rules.Compare(rules.OpHasNone, rules.Var("object.tags"),
rules.List(rules.Lit("restricted"))),
)
Wrong-shaped fields deny, and are recorded
A field of the wrong shape — has over a string, hasAll over a number,
isEmpty over a bool — makes the comparison false. Every collection
operator, no exceptions, including the negative ones: nin over a string is
false, not true. A mismatch never matches, whatever the operator's polarity,
so mistyped data can only ever deny.
expr-lang on its own is not like this. "a" in object.missing is false, but
"a" in object.title is the runtime error operator "in" not defined on string
— so inheriting expr's behavior would mean one mistyped field breaks every
Check that touches it. Load-time validation of the value
model stops mistyped data from the CSV and inline loaders, but
it cannot cover a host-implemented ObjectProvider or the principal attribute
bag, which bypass loaders entirely. Hence a runtime policy as well.
Note the asymmetry with the absent-field table above, and that it is deliberate:
| operand | policy |
|---|---|
absent (nil) | reads as an empty collection; the operator's own semantics decide, so the negative ops match |
| wrong shape | the comparison is false, whatever the operator |
Treating a mismatch as an empty collection would have been more uniform, but it
would make nin / hasNone / subsetOf grant on mistyped data. Deny-safety
wins the tiebreak.
Which shapes each operator accepts:
| operators | accepts |
|---|---|
in nin has hasKey | array (elements) or object (keys) |
hasAll hasAny hasNone subsetOf | array |
isEmpty isNotEmpty | array, object, or string |
exists | anything — a nil test cannot mismatch |
Evaluation notes
A silent false is how an access-control bug hides. So the mismatch is
recorded and surfaced in Explain, on every decision surface:
Explain alice/read on account:acme/document:9 in account acme
subjects: principal:alice
grants considered (1):
g-doc [allow account:acme/**] inclusive scope does not cover the object
evaluation notes (1):
g-doc [rule tagged]: object.tags: expected collection, got string
verdict: DENY (top specificity 0)
A second kind of note covers the other invisible case: an operator that
matched because the field is missing — the nin / hasNone / subsetOf /
isEmpty grant described above.
g-doc [rule tagged]: object.tags: absent; hasNone matched because the field is missing
Notes carry shape and path only — never a metadata value, because a trace
crosses account boundaries the same way an error message does. They are
diagnostic only and never influence a verdict, and only Explain collects them:
Check and Enumerate install no collector, so the decision hot path records
nothing and allocates nothing.
Library callers reach the same channel directly:
ctx, notes := rules.WithNoteCollector(ctx)
allowed, err := compiled.Eval(ctx, in)
for _, n := range notes.Notes() {
log.Println(n) // object.tags: expected collection, got string
}
Other missing-field behavior
- Equality against a missing field is
false(nil never equals a scalar). - Ordered comparison (
lt le gt ge) against a missing field is a runtime error —APERTURE_RULE_EVAL. The rule assumes a field the object does not carry; the scope resolver treats that as a non-decision, not a silent select.
Validation
Node.Validate() checks that a node and its subtree are structurally
well-formed — the closed node set, the correct arities (and/or need ≥ 2
children, not exactly 1, compare the operands its operator calls for), a known
comparison operator, a scalar literal, and a variable whose first path segment
is one of the four roots. It returns APERTURE_RULE_INVALID for a malformed node
and APERTURE_RULE_UNKNOWN_VARIABLE for a variable outside the exposed roots.
Validation is pure structure — it does not type-check and never touches the expression engine. Beyond arity it enforces, per operator:
- A literal carries a scalar (arrays and objects are rejected; use a
listnode for collections). isEmpty/isNotEmpty/existscarry norightoperand; every other operator carries one.inninhasAllhasAnyhasNonesubsetOftake alistor avaron the right.has/hasKeytake a single element on the right — alistthere is an error that points athasAll/hasAny/hasNone.- A
callmay not name one of expr's predicate builtins.
For the deep check — structure plus a full compile pass that surfaces type
errors and unknown functions — call rules.ValidateAST(raw). It decodes a JSON
AST and compiles it against a shared package-level validator (nil source, nil
fetcher — it resolves no references and fetches no metadata), returning nil for a
compilable rule and an APERTURE_RULE_* code otherwise. This is what a
save/validate surface runs before persisting a rule.
Compilation and caching
Node.Expr() renders the validated AST to an expr-lang expression string. The
rendering is direct and injection-free (variable paths are Go-style identifiers,
string literals are quoted, integers keep their exact form via json.Number):
((object?.classification == "public") || (principal?.tier in ["gold", "platinum"]))
Every path segment after the root renders with expr-lang's optional chaining
(?.); the root itself is a typed environment field and needs none. That is what
makes nested reads nil-safe — see Missing fields and nested
access below. The AST is unchanged: a var
node still stores the plain dotted path (object.owner.dept), so the JSON form
the editor and the state file share carries no ?.
Compiler.Compile(node) validates, renders, and compiles that expression to a
reusable *vm.Program via expr.Compile. Every compiler fixes the same options:
expr.Env(evalEnv{})— the typed four-root environment, so any other top-level identifier is an unknown name at compile time.expr.AsBool()— a rule must evaluate to a boolean.expr.DisableAllBuiltins()— expr-lang's builtin library is off, so no wall-clock or random function is reachable and evaluation stays deterministic. It does not cover the predicate builtins;Validatedenies those by name.- The curated pure function set —
lower,upper,contains,startsWith,endsWith,len, plus the collection-operator backingshasAll,hasAny,hasNone,subsetOf,isEmpty,isNotEmpty. A host adds its own deterministic, side-effect-free functions withrules.Function(name, fn)/Engine WithFunction; these join (and can shadow) the curated set. An unknown function is caught at compile time. Note thatcontains,startsWithandendsWithare reserved as infix operators by expr's grammar, so they are registered but cannot be reached through acallnode; the other nine can.
A compile failure that survives validation is a type mismatch, a non-boolean
result, or a call to an unregistered function — all surfaced as
APERTURE_RULE_TYPE_ERROR, with the evaluator's own message preserved in context.
A Compiled is immutable and safe for concurrent evaluation; it carries the
canonical Source() and its Hash() (sha256 of the source).
The compiled-rule cache
Compiling an expression is the expensive step, and Check runs on a tight
latency budget, so a compile happens once per canonical form. The engine keys
a compiledCache by the rule's canonical hash. Two different rule references
whose ASTs render to the same expression share a single compiled program.
flowchart TD
A["rule AST (Node)"] --> V["Validate<br/>structure + roots"]
V --> R["render → expr-lang source"]
R --> H["hash = sha256(source)"]
H --> C{"cache hit<br/>for hash?"}
C -->|"hit"| P["reuse *vm.Program"]
C -->|"miss"| K["expr.Compile → program"]
K --> S["cache.put"]
S --> P
P --> E["Eval(Input) → bool"]
The cache is concurrency-safe and exposes Hits / Misses / Evictions / Entries
via Engine.CacheStats(). An optional TTL (WithCacheTTL, with an injectable
Clock for deterministic tests) bounds entry lifetime; TTL ≤ 0 keeps entries
until explicit invalidation. When a rule's definition changes underneath a cached
compilation, a host calls Engine.Invalidate(node) (drop one) or
Engine.InvalidateAll() (clear).
Evaluation and the engine
Compiled.Eval(ctx, Input) runs the program against an Input — Object,
Principal, and Account maps plus the Action string (a nil map reads as
empty). Evaluation is pure: it reads only the input, mutates nothing (the
object metadata snapshot is treated read-only), and exposes no nondeterministic
function. A runtime failure or a non-boolean result is APERTURE_RULE_EVAL.
rules.Engine ties the pieces together. It is built over a RuleSource (which
resolves an opaque rule reference to its Rule definition — MapSource is the
in-memory default; a missing reference yields APERTURE_RULE_NOT_FOUND) and a
MetadataFetcher (whose signature matches *provider.Registry.Fetch, so a
provider registry wires in directly as the object-metadata
source without the rules package importing provider). An optional
PrincipalResolver supplies principal attributes, keyed by
Attributes(ctx, kind, principal). A resolver returns the host's bag alone;
returning nil is a complete answer, because the engine stamps the floor over
whatever comes back. The kind is model.PrincipalKind's
spelling ("user" / "machine") carried as a string, so one resolver can
dispatch to a different attribute source per kind — a human directory and a
service-account registry are rarely the same store. An empty kind means the
caller did not have the principal's record in hand: treat it as unknown, never
as a default, or a machine gets answered for out of the human directory.
An optional AccountResolver supplies the active account's attributes,
keyed by AccountAttributes(ctx, account). It is the same contract one root
over: the host's bag alone, nil is a complete answer, the engine stamps the
floor. The method is spelled AccountAttributes rather than Attributes so
that one *provider.AttributeRegistry can satisfy both seams — Go has no
overloading, and the registry already holds both directories and both caches.
The floor bag, and principal.kind
principal always carries {id, kind}, whatever is wired. The floor is stamped
last, so a host bag with its own id or kind key cannot shadow it — the
realistic collision is innocent (a directory with an internal id column), and a
floor that can be shadowed silently changes what principal.id == object.owner
compares. An unknown kind is published as the empty string rather than omitted,
so "unknown" is a value a rule can compare against.
kind is published because attribute providers are registered per kind,
which makes a rule silently kind-dependent: a rule reading the user directory
finds nothing for a machine principal. In an inclusive grant that denies
safely; in an exclusive one — where being selected means being excluded — a
rule that quietly stops selecting widens access. principal.kind is how an
author states the dependence instead of hiding it:
principal.kind == "user" && principal.tier == "gold"
The account floor, and the wildcard
account always carries {id} — the active account, the tenancy the
decision is being made in, never the account a grant happens to be stamped to. A
wildcard-stamped grant is evaluated inside whatever account is active, so reading
its attributes off the stamp would give one tenant's decision another tenant's
plan.
The floor is {id} and deliberately not {id, kind}: there is one account
slot, so a kind key would be the same constant in every bag in every
deployment, and a value that can never discriminate is noise a rule author would
eventually compare against. As with principal, the floor is stamped last,
so a host account table with its own internal id column cannot silently change
what account.id == object.account compares.
"*" — the all-accounts grant sentinel — is never an attribute fetch key.
There is no account row for it (ValidateAccount refuses to store one), and the
only bag that could answer "the attributes of every account" is one tenant's data
served as every other's. It is nonetheless a live active account: platform-tier
authority is anchored there, so authz.Gate.RequireSystemAdmin really does run a
Check with it. A decision made at platform scope therefore sees the floor and
nothing else — account.id is "*", which truthfully says "not scoped to a
tenant", and every host-defined field is absent exactly as it is for an unwired
slot. The engine short-circuits before any resolver is consulted, so a
rule-backed grant guarding the system anchor stays decidable; presenting "*" to
provider.AttributeRegistry directly is APERTURE_ATTRIBUTE_PROVIDER_INVALID,
which makes that refusal a backstop rather than the mechanism.
Wiring a *provider.AttributeRegistry
A provider.AttributeRegistry — the host seam that maps each of the three
attribute slots (user, machine, account) to a provider plus a per-slot
cache — is both a PrincipalResolver and an AccountResolver as it stands,
structurally, with provider importing nothing from rules:
attrs := provider.NewAttributeRegistry()
attrs.MustRegister(provider.AttributeSlotUser, userDirectory)
attrs.MustRegister(provider.AttributeSlotAccount, tenantDirectory)
eng := rules.NewEngine(source, objects,
rules.WithPrincipalResolver(attrs),
rules.WithAccountResolver(attrs))
The kind picks the slot: "user" resolves the user slot, "machine" the
machine slot. "account" is a real slot but is not a principal kind, so it
never resolves through WithPrincipalResolver — a tenant's bag must never be
served as a principal's. WithAccountResolver is the only door to it, and it is
keyed on the active account, not on any principal.
A deployment that has no directory to point at declares the bags in the seed
file instead, under attributes:, and
Document.BuildAttributeRegistry returns exactly the registry above — so
aperture check decides on principal attributes with no Go written by the host.
A missing source is not a failed decision. A slot with no registered provider, and a registered provider with no record for the key, both yield the floor and no error, so a deployment with a human directory and no machine directory keeps deciding normally. Everything else — an unreachable directory, a bag the value model rejects — surfaces verbatim with its code and fixups intact and is treated as a non-decision, because an outage must not read as "this principal has no attributes".
Engine.Selected(ctx, rule, object, account, principalKind, principal, action)
is the full path:
- resolve the rule reference through the
RuleSource; - compile-and-cache its AST;
- fetch the object's metadata (empty when no fetcher is configured);
- resolve the principal's attributes for its kind, and stamp the floor over them into a fresh map (the resolver's bag may be cached and shared, and is read-only, transitively);
- resolve the active account's attributes the same way, through the same
read-only contract, and stamp
{id}over them into a fresh map; - build the
Inputand evaluate.
Any step's failure is an APERTURE_* coded error, and the caller treats it as a
non-decision — there is no select-on-error. That signature is exactly
scope.RuleEvaluator, which is how the rule-backed inclusive/exclusive
scope strategies get their variant: the engine is wired as
scope.Deps{Rules: engine}.
One bag per decision
principal and account are constant for the whole decision, so they are
resolved once and memoized for it, exactly as the reference instant NOW is
snapshotted once. The decision engine opens that scope at the same three
boundaries it opens the instant's — Check, Enumerate, Explain.
It is a correctness property, not only a cost one: a bag is served through a cache with a TTL, and a TTL expiring halfway through an enumeration would judge the first candidates against one version of the principal and the last against another — a result set no single view of the principal justifies, with no error anywhere. An absence memoizes (an unwired slot, or a directory with no record, is a complete answer describing a steady state, so a 1,000-object enumeration performs one resolution); a failure does not, and that costs almost nothing, because a failure ends the decision.
The memo is keyed by the subject each bag was resolved for and re-resolves on a mismatch, so a scope travelling somewhere its author did not picture cannot serve one principal's attributes as another's. There is no API to hand a bag in: a caller-supplied principal bag would be a caller-supplied answer to "who is asking".
Under impersonation, principal.* is the effective subject
When a decision resolves under an active impersonation
session, the rule is told about the effective subject — the target under
become, the operator under augment — which is the same principal the
resolved grant set describes. become resolves the target's id and the
target's kind, so principal.* is read from the target's directory.
The invariant is that the rule and the grant set always describe the same principal. A decision resolving the target's grants while reading the operator's attributes is an authorization bug that leaves no mark in a trace.
Audit is unaffected: the request and the decision still name the real operator,
and the trace still records the session. An inert or expired session elevates
nothing and reads the operator's own bag. Note that principal.id therefore
changes meaning under become — a rule comparing principal.id == object.owner asks "does the target own it", which is what that mode means.
Where this leads
Rules are one of two ways a scope strategy can decide object membership; the
other is an explicit id-list. See scopes & scope strategies for how
the inclusive and exclusive strategies consult a rule. The object.* fields a
rule reads come from providers.
Scopes & scope strategies
A grant's pattern bounds which objects the grant can reach. But
"reach" is not always "every match" — a grant might cover every object of a
type in that scope, or only a named id-list, or everything except a few,
or whatever a rule selects. That choice is the grant's scope
strategy, and this chapter covers the pluggable resolvers that implement it.
The code lives in the scope package.
The key separation: the pattern bounds the scope and supplies the specificity the engine's deny-overrides tiebreak consumes; the strategy decides membership within that bound. A resolver never computes specificity — that stays the pattern's job in the engine, so the resolution semantics from literal-only grants are untouched.
The four strategies
| Strategy | Membership within the pattern scope |
|---|---|
literal | exactly the objects the pattern matches (the baseline) |
implicit | every object of the permission's type in scope — unfettered |
inclusive | opt-in: only an explicit id-list, or objects a rule selects |
exclusive | opt-out: all-of-type in scope except an id-list, or objects a rule excludes |
literal is owned natively by the decision engine and is not in the
registry. The scope package ships the other three plus a registry for host-defined
strategies. Membership always composes with the pattern: a listed object that
falls outside the grant pattern is not covered.
The scope reference and Spec
A permission carries its strategy as an opaque string reference; the engine parses
it into a typed scope.Spec with ParseSpec. The grammar is a strategy key
optionally followed by ;-separated name=value params. Identity strings never
contain ;, =, or ,, so those separators never collide with id-list values:
implicit
inclusive;ids=account:acme/document:42,account:acme/document:99
exclusive;ids=account:acme/document:7
inclusive;rule=quarantine-rule
ParseSpec yields Spec{Strategy, IDs, Rule}. An empty reference (or the
explicit literal) parses to the literal strategy, so grants that carry no
strategy keep their literal behaviour. Parsing validates structure only —
known params (ids, rule), non-empty values, no duplicates — and does not
consult the registry, so a custom strategy key parses cleanly and is resolved by
whatever the host registered. Anything malformed is APERTURE_SCOPE_INVALID.
The resolver contract
Each strategy is a ScopeResolver, constructed per evaluation from a
GrantContext (the already-parsed pattern, the permission's object type, the
parsed Spec, and the account/principal/action context) plus runtime Deps:
type ScopeResolver interface {
// hot path — "is this concrete object a member?" — never enumerates.
Contains(ctx context.Context, object identity.Identity) (bool, error)
// bounded enumeration for Enumerate-style callers.
Members(ctx context.Context, pattern identity.Pattern) ([]identity.Identity, error)
}
Contains answers the Check hot-path question and never needs to list objects.
Members performs a bounded enumeration for Enumerate-style callers,
capped by the Deps.MaxMembers the resolver was constructed with — the engine's
configured enumeration bound, or DefaultMaxMembers (1000) when a wiring
configures none. Resolver construction is cheap — small value structs, id-list
membership by linear scan, no per-evaluation map allocation — and holds no cache.
Two seam dependencies
Strategies that need to enumerate "all objects of a type", or evaluate a rule,
reach two seams through scope.Deps. Both default to an inert implementation
so a zero Deps is usable:
| Seam | Supplied by | Default behaviour |
|---|---|---|
ObjectLister | the provider Registry (*Registry matches its signature byte-for-byte) | APERTURE_SCOPE_LISTER_UNCONFIGURED |
RuleEvaluator | the rules Engine (*rules.Engine satisfies it) | APERTURE_SCOPE_RULE_UNCONFIGURED |
RuleEvaluator.Selected(ctx, rule, object, account, principalKind, principal, action) is exactly rules.Engine.Selected — that shared signature is how the
rule-backed path is wired without scope importing rules.
account and principalKind come straight off the GrantContext. They are
there because a rule-backed strategy asks about attributes, and an attribute
only means something once you know whose it is and which account it is read in.
They are parameters rather than context values on purpose: a missing ctx value
degrades silently to an empty account — a quiet allow-or-deny nobody wrote —
where a parameter is a compile-time obligation on every caller. principalKind
is model.PrincipalKind's spelling ("user" / "machine") carried as a string,
since scope imports no model; empty means unknown, never a default.
How each strategy decides Contains
flowchart TD
O["object"] --> M{"pattern matches?"}
M -->|no| N["not a member"]
M -->|yes| S{"strategy"}
S -->|implicit| T1{"terminal type ==<br/>object type?"}
S -->|inclusive| L1{"in id-list?"}
S -->|exclusive| T2{"terminal type match?"}
T1 -->|yes| Y["member"]
T1 -->|no| N
L1 -->|yes| Y
L1 -->|"no, rule set"| RS{"rule Selected?"}
L1 -->|"no, no rule"| N
RS -->|yes| Y
RS -->|no| N
T2 -->|no| N
T2 -->|"yes, in minus-list"| N
T2 -->|"yes, rule excludes"| N
T2 -->|"yes, otherwise"| Y
implicit — membership is the conjunction of the pattern match and a
terminal-type check (the object's last segment is of the permission's object
type). It takes no configuration; supplying an ids list or a rule is a
misconfiguration and is rejected so it cannot silently mask intent. Members
must list the type, so it depends on the ObjectLister.
inclusive — an opt-in. It must declare an id-list or a rule; neither is a
misconfiguration. Contains first requires the pattern match, then: the object's
canonical id is in the list (exact string equality), or — only when a rule is
declared — the RuleEvaluator selects it. A pure list-backed grant never touches
the rule dependency. Members returns the union of the two halves, with both
patterns applied to each and no identity repeated — it has to, or Check and
Enumerate would disagree about the same grant. The list half needs no lister
(the members are the listed ids within both patterns). The rule half is
list-then-filter, exactly as exclusive does it: list the type through the
ObjectLister and keep what Contains selects. There is no reverse index and no
attempt to invert the rule — a rule is an arbitrary expression over object
metadata and is not invertible in general. A rule-backed grant enumerated with no
ObjectLister therefore reports APERTURE_SCOPE_LISTER_UNCONFIGURED rather than
returning nothing, because an empty member list is a legitimate answer; with no
RuleEvaluator it reports APERTURE_SCOPE_RULE_UNCONFIGURED.
exclusive — an opt-out. It requires a minus id-list or a rule (declaring
neither would make it identical to implicit). Contains requires the pattern
match and the terminal-type check, then is a member unless the object is in
the minus-list or the rule excludes it. Members enumerates all-of-type in scope
via the ObjectLister and drops the excluded ones through Contains.
The registry
scope.Registry maps strategy keys to Factory functions. DefaultRegistry()
preloads the three built-ins (implicit, inclusive, exclusive); literal
stays native to the engine and is intentionally absent. A host adds its own with
Register / MustRegister (rejecting an empty key, a nil factory, or a
duplicate with APERTURE_SCOPE_INVALID).
Registry.Resolve(grantContext, deps) builds the resolver for the spec's
strategy, validating the spec via the strategy's factory. An unregistered
strategy yields APERTURE_SCOPE_UNKNOWN_STRATEGY; a spec the strategy rejects
yields APERTURE_SCOPE_INVALID.
reg := scope.DefaultRegistry()
spec, _ := scope.ParseSpec("exclusive;ids=account:acme/document:7")
gc := scope.GrantContext{
Pattern: identity.MustParsePattern("account:acme/document:*"),
ObjectType: "document",
Spec: spec,
Account: "acme",
PrincipalKind: "user",
Principal: "user:alice",
Action: "read",
}
resolver, _ := reg.Resolve(gc, scope.Deps{Lister: providerReg /* , Rules: rulesEngine */})
ok, _ := resolver.Contains(ctx, identity.MustParse("account:acme/document:42"))
// true: matches the pattern, is a document, and is not in the minus-list.
Where this leads
The ObjectLister that every non-list-backed enumeration depends on is the
provider Registry; the RuleEvaluator the rule-backed paths
consult is the rules Engine. For how the decision engine assembles
grants, resolvers, and specificity into a verdict, see the
library Decision API.
Providers
A rule reads object.classification; an exclusive scope
enumerates "every document in this account." Both need domain data Aperture
does not own. That data belongs to the host application — its database, its
API, its source of truth — and Aperture reaches it through providers. A host
implements one ObjectProvider per object-type; a Registry binds each type to
its provider plus a per-type cache and is the seam every consumer resolves
through. The code lives in the provider package; csvprovider (a file) and
sqlprovider (a database) are the two concrete worked examples.
There are two provider seams, and this page covers both. An ObjectProvider
describes the thing being acted on; an AttributeProvider
describes the party asking — the principal and the account a rule reads
principal.* and account.* out of. They share one value model and one cache
design, and differ in everything that follows from an attribute key being a bare
opaque id rather than a segmented identity.
A load-bearing rule: Aperture never persists provider data as a source of
truth. The host owns it; Aperture only ever caches a copy. Cached metadata is
handed back by reference and treated read-only — the cache never copies a map
on read (allocation matters on the Check hot path), so a provider must return a
fresh map per object and callers must never write to a returned map. Because a
value may nest, that contract is transitive; see
the read-only contract below.
ObjectProvider: the host seam
A host implements this once per object-type. It is a pull source — Aperture asks, the host answers — and must be safe for concurrent use:
type ObjectProvider interface {
Fetch(ctx context.Context, id identity.Identity) (Metadata, error)
List(ctx context.Context) ([]Object, error)
Query(ctx context.Context, filter Filter) ([]Object, error)
}
Fetchreturns one object's metadata; a missing object yields anAPERTURE_NOT_FOUNDcoded error, so the Registry can tell "absent" from an operational failure.Listis the unfiltered enumeration of the type.Queryreturns the objects matching aFilter— an optionalPattern(bounds results to matching identities), a set ofFieldspredicates, and aLimit. The zeroFilterselects everything (equivalent toList).
Metadata is map[string]any — an alias, not a named type — so the rules
engine reads each field straight into its expression environment with no
conversion layer. An Object pairs an identity with its metadata; the identity's
terminal segment type is the object-type the provider is registered under.
The Filter.Fields contract
A provider evaluates Fields, but it does not get to define it. Query is
how scope enumeration bounds itself, so two providers disagreeing
about what Fields{"tags": "premium"} selects is two different answers to the
same authorization question. The rule is stated once on provider.Filter and
implemented once in provider.MatchFields; an implementation either calls that
helper or reproduces it exactly (by pushing the predicate into SQL, say).
| Field value | Want | Rule |
|---|---|---|
["premium","launch"] | "premium" | membership — true |
["premium-trial"] | "premium" | membership — false, never substring |
[3, 5] (int64) | 5 | membership — true |
[3, 5] (int64) | "5" | false — a number is not its string spelling |
"gold" | "gold" | equality — true |
{"dept":"eng"} | "dept" | equality — false, not key membership |
{"dept":"eng"} | {"dept":"eng"} | equality — true |
| (field absent) | anything, nil included | false |
Three properties are worth stating outright, because each one closes a failure mode:
- A collection field matches by membership. Equality against a whole array
is never what a caller filtering on a tag list means, and the predicate this
replaced compared against the array's literal rendering (
[premium launch]), so a tag filter could not match anything useful. - Comparison is typed, not stringly. Numbers compare across Go numeric types
by value (
int(5),int64(5),float64(5)are one value), but a number never equals"5"and a string equals only a string. These are the rules engine's own comparison semantics, soEnumeratecannot select an object that aCheckover the same value then denies.provider.ValuesEqualis the exported leaf comparison, andcsvprovider'smembership_equivalence_test.goruns it against a real compiled rule to keep the two from drifting. - Every predicate must hold (the map is an AND), and an object field is
compared by equality, never by key membership — so a scalar want against one
is a plain
false, not a panic and not an accidental rendering match.
The contract reaches past Query. Enumerate's own metadata filter
(engine.EnumerateRequest.Fields, and its EnumerateQuery.Fields /
--field / --fields-json / Twirp fields / MCP Fields spellings) is
evaluated by this same provider.MatchFields, so an enumeration filtered by the
engine and one filtered inside a provider select the same objects. There the
filter runs on candidates that already survived deny-overrides, and it runs
before the enumeration's Limit — see
the decision API.
Because provider is a strict leaf (identity + errors + stdlib), ValuesEqual
reimplements the evaluator's equality rather than importing expr-lang. It
agrees with it on every value the metadata value model admits; the two documented
divergences — time.Time/time.Duration, and a uint64 above math.MaxInt64 —
are both outside that model.
The text-match contract
Fields answers "which objects hold exactly this value?". The properties that
make it trustworthy — exact, typed, coercion-free — are precisely what stop it
answering the other question a caller has: "which objects are people naming when
they type nike?", where Nike, nike, Nike, Inc. and a typo are one intent
and none of them is an exact value.
provider.MatchText(md, query, fields) is that second question, and it lives
beside MatchFields for the same reason: a host that can push a search down into
its own storage — a SQL LIKE, a trigram index, an external search engine — must
be able to rank the way Aperture would, or the ids it proposes and the ids
Aperture would have proposed are two different answers to one question.
provider.ScoreText and provider.NormalizeText are exported for exactly that.
It reads the same value model, and only the string material in it:
| Field value | Searched? |
|---|---|
"Nike, Inc." | yes |
["Nike","NIKE Inc"] | yes — each element scored separately, and a match reports the element |
42, true, nil | no — filtering by one of those is Fields' job |
{"dept":"eng"} | no — a match names its field, and a nested path is not a field name a caller could restrict to |
Both sides are normalised first — lower case, every non-alphanumeric rune a
separator, runs collapsed — which is what makes "Nike, Inc." and "nike inc"
the same string. Each query token then scores its best match against any
candidate token in descending tiers (exact, prefix, substring, approximate), the
base score is their mean, and that is scaled by how much of the candidate the
query accounts for, so "nike" ranks Nike Inc above Nike Air Max Collection.
Approximate matching uses Damerau-Levenshtein distance, so a transposition counts
as the one slip it is; tokens shorter than four runes must be spelled exactly or
be a prefix, because one edit in three characters would let IBM answer for
IBN.
Nothing in Aperture ever decides on a score. The surface that consumes this —
Search — applies it only to candidates the engine has already decided the
subject may act on. See object search.
The metadata value model
Metadata is an alias, so the type constrains nothing. The shape of a field
value is constrained anyway, and deliberately so: metadata goes into the
expression evaluator untranslated, which means a wrong shape is not a false
decision — it is a runtime evaluation error on the Check hot path
(operator "in" not defined on string). Catching that at load is the whole point
of the model.
A field value is one of:
| Kind | Go type | Example |
|---|---|---|
| scalar | nil, bool, string, any Go integer/float, json.Number | classification: "secret" |
| array | []any whose elements are all scalars | tags: ["eng", "oncall"] |
| object | map[string]any of scalars, scalar arrays, or one further object level | owner: {dept: "eng"} |
Two rules bound it:
- Arrays of objects are rejected at any position. Rule authors compare against
arrays with
in/not in, which has no useful meaning over a list of maps. Nested arrays ([["a"]]) are rejected for the same reason. - Typed containers are rejected —
[]string,map[string]string, structs. The model is spelled in the two types the expression environment and JSON share, so a loader normalises once instead of every consumer type-switching.
time.Time is deliberately not a scalar: a rule literal is a JSON scalar and
could never be compared against one, so a loader formats timestamps as RFC 3339
strings.
Dates are string scalars, in two canonical forms
A date is not a fourth shape — it is a string scalar a loader has been told is a date. Such a string must be one of exactly two forms, both UTC:
| Form | Layout | Example |
|---|---|---|
| calendar day | 2006-01-02 | 2026-03-04 |
| timestamp | 2006-01-02T15:04:05Z | 2026-03-04T01:02:03Z |
Granularity is carried by the string itself, so no type tag travels beside the
value. An offset-free timestamp is read as UTC and fractional seconds are
truncated, never rounded. An explicit offset is rejected rather than
converted: a host writing 2026-01-01T00:00:00+05:00 means January 1st, but the
UTC instant is 2025-12-31T19:00:00Z, so accepting it would silently move the
calendar day and year.
The value model itself stays date-blind — ValidateField cannot know which
strings a host means as dates, so it keeps accepting any string. Declaring a
field to be a date, and running its values through provider.ParseDateValue, is
a loader's job — in csvprovider that is the
:date / :datetime column suffix, and in a seed document the
field_types: section. A rejection is
APERTURE_CONFIG_INVALID carrying a machine-
readable reason (provider.DateReasonOf) and never the value, because a date
can be personal data. Comparison goes through DateValue.Compare, which compares
instants: "2026-03-04" and "2026-03-04T00:00:00Z" are the same moment but
sort differently as text, so comparing the stored strings would be wrong at
exactly that boundary.
Depth, counted below the field root
provider.ValueDepth(v) reports a value's container depth: every array or object
entered adds one level, so a scalar is 0 and an empty container is 1. The cap
is 2 by default.
| Value | Depth | Legal? |
|---|---|---|
tags: ["a", "b"] | 1 | yes |
owner: {dept: "eng"} | 1 | yes |
owner: {lead: {name: "x"}} | 2 | yes |
owner: {tags: ["a"]} | 2 | yes |
owner: {a: {b: {c: "x"}}} | 3 | no — past the depth cap |
owner: {members: [{id: 1}]} | 3 | no — array of objects |
tags: [{name: "a"}] | 2 | no — array of objects |
Size, measured structurally
provider.ValueBytes(v) measures a value without serialising it (nothing is
allocated on a load path): a string costs its length in bytes, a number 8, a
bool 1, nil 0, an array the sum of its elements, and an object the sum of
len(key) + value per entry. Container framing costs nothing. The cap is 64
KiB per field value by default.
Validating at load
Validation is load-time, in one place, called by every loader — CSV today, the inline seed next, a database-backed provider later. That is what lets a new loader inherit the semantics instead of renegotiating them.
// defaults: depth 2, 64KiB per value
if err := provider.ValidateMetadata(md); err != nil { return err }
// or tune the caps — the zero ValueLimits means "the defaults", and any
// field left zero keeps its default
limits := provider.ValueLimits{MaxDepth: 3}
if err := limits.ValidateMetadata(md); err != nil { return err }
// one field at a time, for a loader that reports per-column
err := provider.ValidateField("tags", []any{"eng", "oncall"})
A violation is APERTURE_METADATA_INVALID. Its context carries the field
name, the path within the value (owner.members[0]), and the offending Go
type — never the value itself, so a validation failure can be logged and
surfaced without leaking one account's data into another's diagnostics. Fields
and object keys are walked in sorted order, so a document with several offenders
always reports the same one.
The read-only contract is transitive
Once a value can nest, "cached metadata is read-only" has to reach all the way
down. The cache stores the provider's map by reference and never copies it on
read, so the nested maps and slices inside it are shared too — appending to a
[]any you got back races every other reader exactly as writing the top-level
map would.
- A provider returns a fresh map per object, with fresh nested containers. It must not hand out a value it also retains and mutates, and reloading a source builds a new value rather than editing the old one in place.
- No holder — engine, rules, scope, CLI, server, host code — writes to a
Metadatait was given, at any depth. - A consumer that needs to modify metadata copies it (deeply) first.
The Registry: binding, cache, invalidation
provider.NewRegistry() returns an empty registry; Register(objectType, provider, opts...) binds a provider to a type with a per-type cache (rejecting an
empty type, a nil provider, or a duplicate with APERTURE_PROVIDER_INVALID). The
registry is concurrency-safe: providers register at startup and are read on the
hot path under an RWMutex, and each per-type cache is independently safe.
Registry.Fetch(ctx, id) is the read path consumers use. It routes by the id's
terminal segment type (an unregistered type is APERTURE_PROVIDER_UNREGISTERED),
serves from the type's cache when fresh, and otherwise pulls through the provider
and caches the result — a cache hit never calls the provider. A host provider's
error is normalised by providerError: one already carrying an APERTURE_* code
passes through verbatim (so its APERTURE_NOT_FOUND reaches the caller intact),
while a plain error is wrapped as APERTURE_PROVIDER_FETCH.
flowchart TD
C["consumer: engine / rules / scope"] --> F["Registry.Fetch(id)"]
F --> T["route by terminal type"]
T --> H{"cache hit<br/>& fresh?"}
H -->|yes| R["return cached metadata (read-only)"]
H -->|no| P["ObjectProvider.Fetch"]
P --> S["cache.Set"]
S --> R
The registry serves two other roles by matching contracts from other packages without importing them:
Fetchis arules.MetadataFetcher— its signature is exactly what the rules Engine wants for object metadata, so a*Registryis wired in as the fetcher directly.List(ctx, objectType, pattern, limit)is ascope.ObjectLister— byte-for-byte the seam the implicit/exclusive scope resolvers left open, so a*Registryis passed asengine.ScopeDeps{Lister: reg}. It queries the provider, bounds the result by the pattern and the limit, and warms the cache with each returned object's metadata when the provider promises the two bags are the same bag — see The listing and the fetch must be the same bag. A positive caller limit is honoured verbatim, however large;DefaultListLimit(= 1000) is the value substituted for a limit<= 0, not a ceiling. The caller is the authority because the bound on a decision path is set one layer up — the engine's configured enumerate limit, which every network surface sits behind. A direct Go embedder callingreg.List(ctx, t, pat, 1_000_000)receives a million identities; the library is sharp-edged on purpose.
Two enumeration variants sit beside the bounded List: Identifiers returns the
complete, unbounded id set (sorted, for a stable diff — use it to expand an
exclusive allowance into a positive allow-list), and IdentifiersExcept is
Identifiers minus an excluded set.
The listing and the fetch must be the same bag
Every read of the per-type cache is a Fetch — the decision path's
authoritative view of an object, what a rule sees as object.* and what an
Enumerate's Fields predicate is tested against. An entry a listing wrote is
served back through that seam indistinguishably from one Fetch produced. So an
enumeration may only warm the cache when the listed bag is the bag Fetch would
have returned, and nothing in ObjectProvider makes that true:
get_one: SELECT tier, seats, renews_on FROM brands WHERE id = $1
get_all: SELECT 'brand:' || b.id AS id, b.tier FROM brands b # NARROWER
That is a legal, documented pair — Config.FetchQuery and Config.ListQuery are
independent statements, and a deliberately narrow listing over a wide table is a
reasonable thing to write. Warming from it caches a one-field bag under an id whose
real bag has three, for the whole of the type's TTL. A rule then reads
object.seats as absent — not wrong, absent — and every predicate over an
absent field is false: an inclusive grant denies, and an exclusive grant
stops excluding and therefore widens. Nothing in any verdict, trace or note says
why, because a bag of any shape is a legal bag. A listing wider than the fetch
statement is the mirror image: it caches a field Fetch would never produce, which
compares true until the entry expires and false afterwards.
The promise is therefore explicit and opt-in:
// Implemented by an ObjectProvider that guarantees the Metadata its List and
// Query return is the bag its own Fetch would return, for every object.
type FetchCompleteLister interface {
ObjectProvider
ListedMetadataMatchesFetch() bool
}
A provider that does not implement it makes no promise, and a listing through it warms nothing — the restrictive default. It costs one enumeration's worth of fetches per TTL window and stays correct; a slower decision is an operational problem, where a decision computed from a bag no statement of the host's produces is an authorization one.
| Provider | Answer | Why |
|---|---|---|
provider.Static | always true | one map per object, handed to Fetch, List and Query alike |
csvprovider | always true | one parse of one file behind all three methods |
sqlprovider | derived | the list statement's columns minus the id column must equal the fetch statement's columns, as a set — false until both statements have run once |
| a host's own | false unless it implements the interface | a provider that reads both answers from one row mapper can promise; one serving Query from a search index and Fetch from the system of record must not |
sqlprovider derives rather than declares because it can: rows.Columns() is the
statement's SELECT list, so it is the same for every row and unaffected by any row's
NULLs — where comparing two bags would not be, since a NULL column omits its field
and an omitted field is indistinguishable from an unprojected one. There is no
config field and no YAML key for it: an operator's unchecked promise about two
statements is exactly what this replaces. The practical cost is one cold enumeration
per process per SQL-backed type — its candidates each fetch, which is what teaches
the fetch projection — after which every enumeration warms as before.
The registry cannot verify the promise itself, which is why it is asked rather than
checked. Metadata is opaque host data, one object's bags agreeing proves nothing
about the next object's, and comparing per object would cost the very Fetch the
warm exists to avoid.
Columns are cheap to compare; values are not compared at all. Deriving the answer from the two SELECT lists proves the two statements project the same field names. It proves nothing about the values behind them, and the two can diverge while the columns agree:
get_one: SELECT b.tier FROM brands b WHERE b.id = $1
get_all: SELECT 'brand:' || b.id AS id,
COALESCE(b.tier, plans.tier) AS tier -- same COLUMN, different VALUE
FROM brands b LEFT JOIN plans ON plans.id = b.plan_id
Both projections are {tier}, so ListedMetadataMatchesFetch is true and the
enumeration warms the cache — with a tier that comes from the plan, which this
type's own Fetch would never produce. Every decision reading object.tier for
the whole of the TTL window is then computed from a value no Fetch in the
deployment returns, and when the entry expires the verdict changes with nothing
having happened. It is the same class of failure as an unequal projection, arrived
at through the one gap the column check cannot close.
Nothing can close it from inside Aperture — that would mean fetching every listed
object to compare, which is exactly the work the warm removes. So it is a
developer obligation: the two statements must read the same columns from the
same expressions, and a get_all that computes, coalesces, or joins for a column
get_one reads plainly is a bug in the pair even though every gate passes.
Cache tuning and invalidation
Each type's cache is an in-memory LRU (MemoryCache) behind the pluggable
CacheBackend interface, tuned per type at registration:
| Option | Default | Effect |
|---|---|---|
WithTTL(d) | DefaultTTL = 30s | freshness window; d ≤ 0 disables expiry |
WithMaxSize(n) | DefaultMaxSize = 10 000 | LRU cap; n ≤ 0 means unbounded |
WithClock(now) | time.Now | injectable clock for deterministic TTL tests |
Invalidation is explicit: Invalidate(id) drops one object, InvalidateType
clears a type, InvalidateAll clears every cache. Stats(objectType) exposes
Hits / Misses / Evictions / Expirations / Invalidations / Entries for
observability and the latency benchmark. The provider package depends only on
identity and errors — never scope, engine, or model — so it stays a leaf.
Attribute providers
An ObjectProvider answers "what do you know about this object?". An
AttributeProvider answers "what do you know about the party asking?"
— the principal's department and clearance, the account's plan and region — so a
rule can be written about the asker instead of only about the thing being acted
on:
principal.kind == "user" && principal.department == object.department
account.plan == "enterprise"
The two seams are not variants of each other, and the difference is the fan-out. Object metadata is resolved per object: a decision touching a thousand objects reads a thousand bags, each describing something different. An attribute bag is resolved once per decision and then read by every rule against every object in it. Almost everything below follows from that.
The three slots, and there is no fourth
provider.AttributeSlot is a closed set:
| Slot | Constant | Keyed by | Backs |
|---|---|---|---|
user | AttributeSlotUser | a bare principal id | principal.* for a human principal |
machine | AttributeSlotMachine | a bare principal id | principal.* for a service account, API client, or job runner |
account | AttributeSlotAccount | a bare account id | account.* for the tenancy a decision is made in |
The object Registry is an open, type-keyed map because the host's object
types are the host's business. The slots are not: they are the parties a decision
has, and a decision has exactly these. An open map would let a host register a
fourth "kind" of subject nothing in the engine knows how to fetch — discovered at
evaluation time as an empty bag, which is to say as a silent denial. A further
distinction is a field in the bag, never a fourth slot. user and machine
are separate slots because in every host that has both, the two are served by
different systems.
The interface is three methods, and the key is the whole difference from an object provider:
type AttributeProvider interface {
Fetch(ctx context.Context, id string) (Metadata, error)
List(ctx context.Context) ([]AttributeRecord, error)
Query(ctx context.Context, filter AttributeFilter) ([]AttributeRecord, error)
}
An AttributeRecord pairs one bare string key with a Metadata bag. An
object identity is a segmented path (account:acme/project:atlas/document:42)
precisely so a scope can contain and pattern-match it; an attribute key is an
opaque handle into the host's directory with no hierarchy to spell and no
containment relation to anything. A provider returns APERTURE_NOT_FOUND for a
key it does not know, must be safe for concurrent use, and owes the same
transitively read-only contract an
object provider owes — more strictly, in fact, because one attribute bag is
shared across every object in a decision and every concurrent decision for that
subject.
The bag is a provider.Metadata: the same value
model, the same depth and size caps, the same
ValidateMetadata, the same two canonical date forms, the same number
normalisation in every loader. There is no second model, so
principal.clearance == 3 answers identically whether the bag was authored in
YAML, read from a CSV :int column, or read from a SQL integer.
The registry, the two layers, and the revocation window
provider.AttributeRegistry binds each slot to a provider plus its own
cache — its own TTL, size cap, and counters — because the three slots have
genuinely different change rates and cardinalities:
attrs := provider.NewAttributeRegistry()
attrs.MustRegister(provider.AttributeSlotUser, dir, provider.WithTTL(60*time.Second))
attrs.MustRegister(provider.AttributeSlotAccount, tenants)
A slot holds up to two providers, in two named layers, and the method picks the
layer. Register / MustRegister fills the shared layer
(provider.AttributeLayerShared) — the deployment's own wiring: a shared wiring row,
a seed attribute_providers: entry, the one directory a host administers fleet-wide.
RegisterLocal / MustRegisterLocal fills the local layer
(AttributeLayerLocal) — this instance's own: a seed attributes: block, or a
provider this binary registers for itself.
A fetch reads the two layers' merge, and the shared layer wins every key both
serve. The local layer can only add keys the shared layer does not serve, never
change one it does — because a rule is written against a deployment, and if a
local bag could override a shared key then a file on one machine would change what
principal.clearance >= 3 compares against on that machine only: the same rule, a
different verdict, with nothing in a verdict or a trace to say which layer answered.
The precedence is fixed and does not depend on registration order. It is the engine's
floor bag one tier down, floor included:
the winner is stamped last over a fresh map and the floor then stamps over both, so
the tiers compose in one direction — floor over shared over local.
"Shared wins" is about keys the shared layer serves, and absence used to be the hole in it. The merge copies the local bag and stamps the shared one over it, so a key the shared bag does not carry is answered from the local one — which is the whole point, and which used to have two consequences nothing could distinguish from it:
- a shared source that omits a key for one subject fell through to the local
layer, inside its own declared set. A SQL
NULL(and a JSONnull) becomes an absent field, not a null value, so a directory whoseclearanceis NULL for one person returns a bag with noclearancekey, and the merge read that person'sclearanceout of the local file while the rule validated cleanly. - removing a subject from the shared directory was not a revocation on an
instance whose local file still listed them. A shared layer with no record for a
key reports
APERTURE_NOT_FOUND, which a fetch reads as this layer has no record — the ordinary case — and answered from the local bag. It never timed out either: an inline layer is registered with a TTL of0because inline data cannot change while the process runs, so invalidation had nothing to drop.
A declared key set closes both. declared_keys: on the shared entry —
provider.WithDeclaredKeys in Go — does a second job with the same list: when a
layer declares a key set, only that layer may answer the keys in it. A local value
for a declared key is dropped, whether the declaring layer returned a different
value, returned the key absent, or returned no record at all. The second half
is a revocation control: deleting a subject from the shared directory removes every
declared key for them on every instance, whatever a local file still says. See
declared_keys: for the seed
spelling and the three states.
Three things it deliberately does not do:
- It is opt-in. A layer that declares nothing reserves nothing, so a deployment
that has never written a
declared_keys:merges exactly as it did.declared_keys: []is declared empty and likewise reserves nothing — a different state with the same effect. - It is not a discard. Suppression is scoped to the declared set; a key outside it still answers from the local layer, which is what the local layer is for. Reversing the precedence instead would reinstate the mutual exclusivity this layering replaced, and that was worse.
- It cannot reach the floor.
principal.id,principal.kindandaccount.idare stamped last and are never part of a declared set, so naming them is redundant rather than required or refused.
Only the shared layer may declare: RegisterLocal with a declared set is
APERTURE_ATTRIBUTE_PROVIDER_INVALID, because a local declared set would be one
machine deciding which keys the deployment's own directory may answer.
A slot that declares nothing still has both behaviours above, and nothing can
infer a set for it — a bag is opaque host data and an absent key is indistinguishable
from a genuinely unset one. Declaring is the fix. It is still good practice to write
a shared get_one that answers for the keys it promises
(COALESCE(u.clearance, 0) AS clearance), and to use the local layer for fields
the directory does not carry rather than for subjects it is the register of.
A second registration in the same layer is still refused, not replaced: "last
writer wins" is how one deployment's directory quietly shadows another's during
wiring, and the failure then surfaces as attributes that are merely wrong rather
than absent. A slot therefore accepts exactly two providers and a third is
APERTURE_ATTRIBUTE_PROVIDER_INVALID whichever layer it names. A slot with only one
registration behaves exactly as it always did — one provider, one cache, the bag
verbatim — whichever layer it sits in, and a slot left unregistered is not an error:
a deployment with no machine principals wires no machine provider.
Each layer caches independently, and that is deliberate: a layer's TTL is its own
revocation window, declared by whoever declared that layer, so one pooled cache per
slot could honour at most one of two declarations. Taking the longer window would
silently lengthen the time a revoked shared attribute keeps authorizing; taking the
shorter one would silently ignore a declaration an operator made. CacheConfigFor
reports the governing (shared, when filled) layer's configuration,
CacheConfigForLayer one layer's, and Stats sums them — so a key both layers serve
counts twice, because it really is cached twice.
A declared set is the other revocation control, and the one a TTL cannot give you:
invalidation and a short TTL close the window on a bag that changed, and neither
closes it on a subject the shared directory no longer has at all, because the local
layer answering out of its own ttl: 0 cache has nothing stale to drop.
Staleness is not only a tuning knob here. An object's metadata going stale for a
TTL is usually tolerable: a document's category is a fact about a thing. An
attribute bag is the asker's standing — the clearance, the department, the
plan — so until a cached bag expires, every decision about that subject is made
against access the host may have already taken away. Pick a slot's TTL for
how fast its revocations must land, and close the window explicitly when you
cannot wait: Invalidate(slot, id) drops one subject (and reports whether an
entry was present), InvalidateSlot(slot) a whole directory, InvalidateAll()
everything. All three clear every layer of every slot they name — clearing one
and leaving the other would be worse than not clearing at all, since the operator has
been told the window is shut while half of it is open. Invalidation is
process-local: it clears the caches of the process that runs it and cannot reach a
different one.
Leniency: a missing bag decides, a broken directory does not
The registry satisfies the rules engine's two resolver seams structurally,
without importing rules:
eng := rules.NewEngine(ruleSource, objectRegistry,
rules.WithPrincipalResolver(attrs), // Attributes(ctx, kind, principal)
rules.WithAccountResolver(attrs)) // AccountAttributes(ctx, account)
Two outcomes are lenient — they yield a nil bag and no error, so the decision proceeds against the engine's floor bag:
- the slot has no registered provider, or the principal's kind names no principal slot at all;
- a registered provider has no record for this key (
APERTURE_NOT_FOUND).
Everything else — an unreachable directory, a bag the value model rejects — surfaces verbatim, keeping its code and its registry fixups, and every consumer treats it as a non-decision. That distinction is the point of the seam: an outage must not read as "this principal has no attributes", because that is an authorization change wearing an infrastructure failure's clothes.
Leniency is asked of the slot, not of a layer, and two layers do not widen it.
Inside a fetch, one layer's APERTURE_NOT_FOUND means only this layer has no record
for the key, and the other layer's bag is the answer; every other error surfaces
verbatim from whichever layer raised it, so an unreachable shared directory is never
quietly answered out of the local file.
Leniency leaves one hazard, and it is accepted rather than solved. An absent
attribute makes every comparison against it false. In an inclusive grant
that is deny-safe. In an exclusive grant, selection means excluded — so a
rule that quietly stops selecting stops excluding, and the object the exclusion
was written to withhold becomes covered, with nothing in the verdict saying so.
The mitigations are visibility, not refusal: principal.kind, so an author can
state a rule's kind-dependence out loud, and the attributes_floor_only
evaluation note, so a trace says the bag was empty.
A principal bag is global, and keeping it account-neutral is a host obligation. A fetch is keyed by the bare principal id alone — it carries no account — so one principal's bag is visible to rules evaluating in every account that principal is a member of. Facts about the person or the machine are account-neutral; facts about the person in one tenancy are not, and putting one in a principal bag publishes one account's data into every other account that principal touches. Aperture cannot detect it: the values are opaque host data. Per-tenant facts belong on the account slot, which is bounded — the account bag is always resolved from the active account.
The containment boundary: enumeration is never scope resolution
*provider.Registry deliberately does satisfy scope.ObjectLister, which is
how an exclusive scope enumerates a type. *provider.AttributeRegistry
deliberately does not.
If the principal directory were reachable through that seam, the principal table would become an enumerable object set inside a decision — every principal in the deployment listable by anything holding a lister, bounded only by the grant's own scope, with no admin tier consulted.
Go's typing is structural, so intending otherwise is worth nothing: a method
with a matching signature satisfies the interface whether or not anybody meant it
to, and the wiring mistake it enables is silent. So the containment is structural
too, four times over — enumeration is called Enumerate, not List; it is
keyed by an AttributeSlot, not a bare object-type string; it takes an
AttributeFilter, which carries no identity.Pattern to bound with; and
it returns []AttributeRecord (bare keys), not []identity.Identity. Any
one of the four makes the signature unassignable; all four make it unassignable
by accident. TestAttributeRegistryIsNotAScopeLister asserts the negative
against the real interface, with *provider.Registry as the positive control.
AttributeFilter carries no pattern for the same reason. There is nothing to
match — an attribute key has no segments, so a pattern over it could only be a
substring test dressed as containment — and Filter.Pattern exists solely to
bound an enumeration to a grant's scope. Its Fields predicate is exactly the
object seam's Filter.Fields contract, and both
Fields and Limit are re-enforced by the registry on whatever a provider
returns, so a provider that ignores them is still correct and no caller can
materialise an unbounded directory.
There is no ceiling on the limit, but there is a default, and it truncates
silently. A positive Limit is honoured verbatim however large — an operator
asking "who is in the user slot?" may legitimately need all of it — but a
non-positive one means DefaultListLimit (= 1000), so a read that names no limit
is not an unbounded one. Nothing in the result says which happened: a
5000-subject slot read with no limit returns exactly 1000 records with no error,
no flag and no count, and that is indistinguishable from a complete directory
of 1000. To read a whole slot, name a limit larger than it
(aperture attributes query user --limit 6000); to find out whether an answer is
complete, ask for one more record than you expect and see whether you get it.
Enumeration is therefore reachable from exactly one place: service.ListAttributes,
a system-tier administrative read gated through authz.Gate.RequireSystemAdmin,
surfaced as aperture attributes query. The decision
path's Fetch is not gated and must never be — a decision resolves one bag for a
subject it already named.
The listing does not write the decision path's cache
AttributeRegistry.Enumerate is read-only all the way down: it never warms the
slot's cache — neither layer's. Fetch still caches its own answer, per layer; only
the listing's bags are excluded.
On a slot with two layers, Enumerate queries both and merges the records per key,
the shared layer winning — and its declared keys reserved to it — exactly as a fetch's
merge does, so a listing shows the bag a
fetch of that key would return. Fields and the limit are re-enforced on the
merged bag: filtering per layer would drop a record whose merged bag does match,
and a limit applied per layer would truncate before the merge could finish a record.
Fetch and Query answer different questions, and nothing in AttributeProvider
makes their bags equal. The SQL loader makes the inequality legal:
AttributeConfig.ListQuery is optional and only has to select a bare id, so a
get_all projecting two columns beside a get_one projecting four is a correct
pair in which Query's bag is a strict subset of Fetch's. Warming the fetch
cache from it substituted the display projection for the authoritative bag, for
the whole of the slot's ttl.
That is an access-control change rather than a stale read, because an absent key
is not a wrong key. Every predicate over it goes false, so an inclusive grant
denies and an exclusive grant stops excluding — an operator running
aperture attributes query user would silently widen access until the ttl
expired, with nothing in any verdict or trace to say why.
The object Registry.List had the same bug from the same cause and is fixed
differently — its warm is conditional rather than removed, because it is a
decision-path call whose Fetch follows in the same candidate walk. See
The listing and the fetch must be the same bag.
Enumerate has no Fetch behind it, so its warm bought nothing and cost the
decision path its bag; an AttributeProvider is therefore given no promise to make
about the two, deliberately.
Where the bags come from
| Implementation | Source | Notes |
|---|---|---|
provider.StaticAttributes | an in-memory slice | immutable after construction; everything validated up front, so a read can never fail for a reason wiring could have reported |
csvprovider.NewAttributes(path) | one CSV file | the same header grammar and column-type suffixes as the object loader, but loaded eagerly: a malformed file is a coded error at boot naming the row, because an unparseable attribute file is not one type failing to answer, it is every decision for that slot |
sqlprovider.NewAttributes(q, cfg) | two statements over a Querier | the same driver-value mapping, value model, and casting rules as the object provider |
Declaratively, a seed document's attributes:
block lists bags inline and
attribute_providers: points a slot at a
file or a connection. Both are runtime wiring, never model state. A slot the
document fills both ways gets both, in the two layers above — the
attribute_providers: entry shared, the inline block local, the shared one winning
every key both serve, and nothing dropped. See Precedence: two layers, and the shared
layer wins.
One asymmetry is worth repeating here because nothing can catch it: an attribute
provider's keys are bare ids. A CSV id column holds alice, not
user:alice; a SQL get_all selects u.id AS id, not 'user:' || u.id AS id.
An identity-shaped key is a legal opaque string that enumerates and caches
happily and then matches no id any fetch ever presents, so the slot silently
never answers. See the bare-id
contract.
Declared references
A registry also holds references: declarations that one object-type's
metadata field holds identities of another object-type. dataset.current_brands → brand is an application-level foreign key — nothing in a database enforces
it, so the declaration lives beside the provider that serves the field.
providers:
- object_type: dataset
kind: sql
connection: main
get_one: SELECT d.tier, to_jsonb(d.brand_ids) AS current_brands FROM datasets d WHERE d.id = $1
get_all: SELECT 'account:acme/dataset:' || d.id AS id,
to_jsonb(d.brand_ids) AS current_brands
FROM datasets d
references:
current_brands: brand # field name → target object-type
reg.MustRegister("brand", brands)
reg.MustRegister("dataset", datasets)
reg.MustDeclareReference("dataset", "current_brands", "brand")
Both paths reach the same Registry.DeclareReference, so anything expressible in
YAML is expressible in Go. ReferenceTarget(type, field), References(type) and
AllReferences() read the declarations back as a registry lookup rather than a
re-parse of the document, and ResolveReference(ctx, id, field) turns one
object's field into the identities it names.
Three properties are closed on purpose:
- The holding side only. A reference is declared on the type whose provider
actually returns the field. There is no inbound form on
brand, becausebrandhas no column listing its datasets — an inbound declaration would describe a derived view with nothing to attach to, and a second referencing field (archived_brands) would make the unnamed reverse edge ambiguous anyway. - One descriptor kind. A reference names its target object-type and nothing
else. There is deliberately no
type:key: the loader is the single typing mechanism (a CSV column suffix, a cast in the developer's SQL), and a second place to declare a type is a second place for the two to disagree. - Values are full canonical identities —
"account:acme/brand:1", composed by the developer where the data is loaded, never a bare primary key Aperture would template. That is what lets the ordinaryFilter.Fieldscontract match one with no new code:{"current_brands": "account:acme/brand:Y"}is just a membership test over a list of strings.
Declaring a reference on a field no object happens to carry is not an error —
metadata fields are discovered at fetch, not declared, so it resolves to nothing.
A value that does not point where the declaration says — a "team:7" in a
field declared to hold brands — is an error
(APERTURE_PROVIDER_REFERENCE_MISMATCH), because an enumeration that silently
dropped it would read as "no access" and hide the fault. A target type with no
registered provider is APERTURE_PROVIDER_REFERENCE_INVALID at build.
A seed document applies its references: blocks in a second pass, after every
type is registered, so a reference may name a target declared further down the
file or served by the objects: section. Like the rest of providers:, a
declaration is runtime wiring: Apply writes none of it and an export reproduces
none of it — and, like the rest of providers:, it is shared wiring.
aperture wiring push flattens the map into apt_wiring_provider_references, one
row per (object_type, field), and an instance booting against those rows builds
the same declarations. The target is still resolved against the registry the wiring
builds rather than against a table, which is why a declaration may point at a type
served only by a local objects: entry and why that column carries no foreign key.
See aperture wiring.
What a declaration buys: enumerating through it
Enumerate can be restricted to the identities a holder object's declared field
contains:
aperture enumerate alice read 'account:acme/brand:*' \
--seed ./model.yaml --via account:acme/dataset:x.current_brands
The mirror-image question — "which datasets contain brand Y?" — is a filter, not a dereference, and the metadata filter already answered it. The two look symmetric and are not: the dataset holds the field, so a predicate on dataset expresses that question; a brand holds no field naming its datasets, so no predicate on brand can express the first one at all.
Its security semantics are the reason the dereference lives in the engine rather than being a caller-side two-call workaround, and they are deliberately asymmetric:
- a holder the principal may not read yields an empty result and no error — "you may not see dataset X" and "dataset X contains nothing you may see" must be indistinguishable, or the edge is an oracle for objects the caller was never allowed to know about;
- an absent holder is
APERTURE_NOT_FOUNDonly inside the request's account and only for a member — outside the account, or for a non-member, the answer is empty and neverNOT_FOUND; - a dangling identity (referenced, no longer served) is skipped, logged at
warning, and noted as
dangling_reference, never a failed decision; - exactly one hop is taken, several edges AND, and the restriction is
applied before
limit.
A rules-engine dereference is deliberately not supported: Check owes a p99
under a millisecond, and a join on the decision hot path — with a recursive
cache-miss path behind it — is a cost that belongs to the host's data rather than
to the rule. Enumeration computes the restriction once, off that path.
The full model, including the exact ordering and the per-surface tests that pin
it, is in skills/object-references.md.
Worked example: csvprovider
csvprovider implements ObjectProvider over a CSV file, so a host can wire real
object data during development before pointing Aperture at its database. It is a
drop-in adapter: register a *Provider under an object-type exactly as the
SQL-backed provider is registered, and the
Registry's cache, invalidation, and rules wiring are unchanged.
reg := provider.NewRegistry()
reg.MustRegister("brand", csvprovider.New("brands.csv"), provider.WithTTL(0))
reg.MustRegister("app", csvprovider.New("apps.csv"), provider.WithTTL(0))
// swapping to a database later changes only these two lines.
File shape
The first row is a header. One column must be named id and holds each
object's canonical identity string; its terminal segment type is the object-type
the provider is registered under. Every other column becomes a metadata field
keyed by the column name. A column name may carry a type suffix so its cells are
coerced to a real type the rules engine reads natively. The full grammar is:
name:type[<elem>][(delim)]
Scalar columns
id,category_id,seats:int,active:bool,budget:float
brand:1,electronics,40,true,15000.50
brand:5,books,12,false,3000
brand:23,garden,,true,
Scalar types are string (the default, no suffix), int (stored as int64),
float (float64), and bool. An empty cell omits that field for the row,
so a rule can supply its own default (row brand:23 above has no seats or
budget).
Date columns
The types date and datetime declare a column to hold
dates, so every cell is
validated and canonicalised at load through provider.ParseDateValue:
id,tier,hired_at:date,last_seen:datetime
brand:1,gold,2026-03-04,2026-03-04T12:30:00Z
brand:2,silver,,
The point is where the failure lands. A typo'd or impossible date in an untyped column is a perfectly good string that no rule can compare, so it becomes a silent deny at decision time — months later, in production. Declared as a date it is a hard error on the line and column that hold it.
The canonical string is what is stored, not the cell as written:
2026-03-04T12:30:00.750Z and 2026-03-04T12:30:00 both become
2026-03-04T12:30:00Z. Two rows naming one instant are therefore one string,
which is what makes a Filter.Fields equality predicate over the column mean
anything. That predicate must itself be canonical; range querying is not a
provider concern — rules are where date ranges live.
| Suffix | Cells | Stored as |
|---|---|---|
:date | a calendar day | 2006-01-02 |
:datetime | an instant | 2006-01-02T15:04:05Z |
Four rules, each because the alternative is a silently wrong answer:
- The declared type fixes the granularity. A
:datecolumn rejects a timestamp and a:datetimecolumn rejects a bare day, rather than quietly widening it to midnight. Write the midnight out. - An explicit offset is a load error, not a conversion.
2026-01-01T00:00:00+05:00means January 1st to whoever wrote it, and its UTC instant is2025-12-31T19:00:00Z— converting silently moves the year. AZsuffix is accepted, and so is an offset-free timestamp (read as UTC). - An empty cell omits the field, following the scalar rule (row
brand:2above has neither). An absent date differs meaningfully from any date, and a zero time would silently satisfy everybeforerule written against the column. - A date-shaped string in a
:jsoncell is not date-validated.:jsonis opaque structured data; only a declared column gets date treatment.
There is no :list<date> — arrays of dates are out of scope, and the suffix is
rejected by name rather than by accident — and no time-of-day type. A rejection
is APERTURE_CONFIG_INVALID naming the column, the line, and the field, carrying
the provider.DateReason and the layout expected, and never the cell: a date
is frequently personal data.
Array columns
The type list produces a real []any — the array
of the value model — which is what makes "premium" in object.tags decide
correctly instead of string-matching a delimited blob (a blob match also matches
"premium-trial" and grants access it shouldn't):
id,tags:list,seats:list<int>,aliases:list(;)
brand:1,premium|launch,3|5,acme;acme-co
brand:2,,1,bcorp
| Suffix | Elements |
|---|---|
:list | strings, split on | |
:list<int> / :list<float> / :list<bool> | each element coerced through the same scalar path |
:list(;) | strings, split on ; — that column only |
:list<int>(;) | both, in that order |
Element typing is not decoration. The expression evaluator does no
numeric/string coercion, so 5 in object.seats is false against the strings
["3","5"] — a silently wrong false, the worst failure mode an access-control
engine has. :list<int> is what prevents it.
There is no escape syntax. A value that must contain the delimiter needs a per-column delimiter its data does not contain. A stray, doubled, leading, or trailing delimiter — how a delimiter inside a value looks to the parser — yields an empty element and is a hard error at parse, never a silently mis-split row.
An empty cell in a list column is the one departure from the scalar rule: it
yields an empty list ([]), not an absent field, so a membership rule
evaluates to a definite false rather than running against nil (row brand:2
above has tags: []).
Object columns
The type json parses its cell as JSON, so a rule can read a structured value
with a dotted path — object.owner.dept. The cell must decode to a JSON
object at the top level; an array, a scalar, or null is rejected, because
list stays the only array path. That keeps "arrays hold scalars, objects hold
structure" true everywhere and the operator set flat.
A JSON object contains commas and quotes, so the cell has to be quoted per
RFC 4180 — the whole cell in double quotes, with every inner double quote
doubled. encoding/csv handles this correctly; the part that trips authors up
is writing it:
id,owner:json
brand:1,"{""dept"":""eng"",""lead"":""alice""}"
brand:2,"{""dept"":""ops"",""tags"":[""oncall"",""eu""]}"
brand:3,
Below the top level it is ordinary JSON, bounded by the
value model's depth and size caps:
{"dept":"eng","tags":["a","b"]} is fine (depth 2) and {"members":[{"id":1}]}
is not — arrays of objects are rejected at any position.
Numbers follow the scalar columns exactly. The cell decodes through
json.Decoder with UseNumber, so nothing is floated before the type is
chosen, and each number then becomes an int64 when it is an exact integer that
fits one (as :int and :list<int> produce) and a float64 otherwise (as
:float and :list<float> produce). 3 is int64(3), 1.5 and 1e3 are
float64, and 9007199254740993 survives as an exact int64 rather than
losing its last digit. That consistency is what makes a cross-column comparison
such as object.owner.seats == object.seats behave. A number no int64 or
float64 can represent is a hard error, not a silent Inf.
An empty cell in a json column omits the field, following the scalar rule
rather than the list rule (row brand:3 above has no owner): an object that is
absent is meaningfully different from one that is empty, and reading an absent
object is safe.
Errors
A missing id column, a duplicate id, a wrong column count, an unknown type or
malformed type suffix, a value that will not coerce to its declared type, a list
cell with an empty element, a json cell that is not valid JSON or does not
decode to an object, or a date cell that is not a canonical date of its column's
granularity is an APERTURE_CONFIG_INVALID error naming the column —
and, for a cell, the line and the offending element. A malformed id passes
through as the identity package's APERTURE_IDENTITY_INVALID. Every parsed value
is then checked against the value model with
provider.ValidateField, so a shape, depth, or size violation fails the load
as APERTURE_METADATA_INVALID instead of surfacing as a runtime error on the
Check hot path. A json cell's rejection carries the column, the line, and the
JSON kind or the decoder's message; a date cell's carries the column, the line,
the reason, and the layout expected. Neither ever carries the cell, which is
host data — and, for a date, frequently personal data.
Loading and the read-only contract
The file is read once, lazily, on the first Fetch/List/Query and held
in memory. New(path) never fails at construction — a bad file surfaces on first
use (the file may not exist yet at wiring time). FromReader(r) builds an
already-loaded provider from any reader (embedded data, tests). Reload
re-reads the file, building a fresh set and swapping it in atomically, so maps
already handed to and cached by the Registry stay immutable — honouring the
"metadata is read-only" contract. That holds at depth: every list cell is parsed
into a slice, and every json cell decoded into a map, allocated for that row
alone, so no two rows — and no two loads — ever share one. After a Reload, call Registry.InvalidateType to drop the
now-stale cache entries.
Query honours Filter.Pattern and Filter.Limit directly and hands
Filter.Fields to provider.MatchFields, so it inherits the contract
instead of restating it — a :list column matches by membership, everything
else by typed equality, and a field absent from a row never matches:
p.Query(ctx, provider.Filter{Fields: map[string]any{"tags": "premium"}}) // rows whose tags contain premium
p.Query(ctx, provider.Filter{Fields: map[string]any{"ranks": 5}}) // a :list<int> column, matched by value
p.Query(ctx, provider.Filter{Fields: map[string]any{"tier": "gold"}}) // scalar equality
The column's declared type is what makes the second one work: :list<int> holds
int64 elements, so 5 matches and "5" does not — the same answer a rule's
in gives over the same data. The Registry re-enforces the pattern and limit, so
honouring them in the provider is an optimisation that also keeps Query correct
when called standalone.
Like the core packages, csvprovider imports only errors, identity, and
provider plus the standard library — pure-Go and CGO-free.
Worked example: sqlprovider
sqlprovider implements ObjectProvider over a relational database, so a host
serves its real objects from the tables it already has instead of exporting them
to a CSV. It is a drop-in sibling of csvprovider — the Registry's cache,
invalidation, and rules wiring are identical — and it is a host data source,
unrelated to Aperture's own storage and sharing no connection
handling with it.
db, err := sql.Open("pgx", os.Getenv("DATABASE_URL")) // the host's driver, the host's pool
if err != nil {
return err
}
defer db.Close()
brands, err := sqlprovider.New(db, sqlprovider.Config{
ObjectType: "brand",
FetchQuery: `SELECT tier, seats, to_jsonb(tags) AS tags FROM brands WHERE id = $1`,
ListQuery: `SELECT 'brand:' || b.id AS id, b.tier, b.seats FROM brands b`,
})
if err != nil {
return err
}
reg.MustRegister("brand", brands, provider.WithTTL(30*time.Second))
A seed document declares the same thing in YAML with no Go code at all.
Cast it in the statement
This is the part a developer cannot skip. The statement is the only typing
mechanism there is — there is no :int / :list<T> suffix here and no
per-column type declaration in YAML, because the developer is already writing a
SELECT list and two spellings for one intent drift apart. A column becomes a
metadata field of whatever Go type database/sql scanned it into.
| Rule | Write |
|---|---|
| An array must be cast to JSON — the only way a list-valued field arrives as a list | to_jsonb(tags) AS tags |
A day-granular date must be cast to text — every time.Time becomes the datetime form | hired_on::text AS hired_on |
A numeric must be cast — ::float8 if it is a number, ::text if it is an identifier | amount::float8 AS amount |
The identity is composed in the id column, by the developer; Aperture supplies no template | 'brand:' || b.id AS id |
The trap this package cannot catch for you
Selecting an array column without casting it compiles, runs, and is wrong:
SELECT tags FROM brands WHERE id = $1 -- WRONG
SELECT to_jsonb(tags) AS tags FROM brands WHERE id = $1 -- RIGHT
A Postgres text[] does not arrive as a list. It arrives as the raw array
literal — the string {a,b} — which is a perfectly valid metadata string,
indistinguishable from a string a host meant to store. Nothing in the provider
can tell them apart, so nothing will complain. What happens instead is that
every membership predicate over that field silently matches nothing, forever,
and the rule reads as though it never applies. If a list-valued field is matching
nothing, check its cast first.
Driver values become metadata
The mapping from a scanned Go type to a metadata value is a closed table, not an inference — an inference would be a value the expression evaluator silently mis-compares:
| Scanned Go type | Metadata value |
|---|---|
nil (SQL NULL) | the field is omitted — the same absent-vs-zero rule as an empty CSV cell |
bool / int64 / float64 / string | the scalar, as-is |
[]byte | JSON-decoded — how arrays and nested objects arrive |
time.Time | .UTC(), then the canonical 2006-01-02T15:04:05Z |
| anything else | APERTURE_SQL_PROVIDER_SCAN naming the column, the row, and the Go type |
A []byte is JSON unconditionally — it never falls back to a string, because
a fallback would let one column change type depending on its contents. So a
bytea column does not work: encode it in the statement (encode(bytes,'base64'))
or leave it out. A time.Time is converted to UTC first, because a
timestamptz comes back in the process's local zone, then routed through
provider.ParseDateValue like every other loader's date. Numbers inside a JSON
column normalise exactly as scalar columns do, so
object.limits.seats == object.seats is not a silent false. Every mapped row is
then checked against the value model.
Fetch, List, and the id column
Fetch binds the identity's terminal segment value, not the identity string
— brand:42 and account:acme/brand:42 both bind "42" — so the statement can
say WHERE b.id = $1 against the primary key it already has and hit its index.
Placeholders are engine-native and passed through untouched ($1 for
Postgres, ? for MySQL or SQLite); there is no dialect rewriting. Parameters are
always bound, never interpolated — that is the SQL-injection boundary of the
feature and it is not configurable.
Zero rows is APERTURE_NOT_FOUND; more than one row is
APERTURE_SQL_PROVIDER_AMBIGUOUS and the first row is never silently taken,
because without an ORDER BY which row that is would be unspecified, and an
object's metadata must not vary between two identical Checks.
List and Query run a second statement that takes no parameters, and its
id column carries each row's full identity. The id column takes a string, or
a []byte read as raw text — deliberately unlike a metadata column, where a
[]byte is JSON, because the id is not metadata and has no competing JSON
reading. A row Aperture cannot place — no id column, a NULL/empty/non-textual id,
an unparseable identity, or an identity whose terminal segment type is not this
provider's object-type — is APERTURE_SQL_PROVIDER_ROW_IDENTITY naming the row's
position, never a row silently skipped. A short enumeration reads as "no access"
one layer up, and a wrong-type row would be cached under an identity this
provider's own Fetch could never return.
Project the same columns in both statements unless you mean not to. The two
SELECT lists are what decide whether an enumeration may warm the Registry's cache
(ListedMetadataMatchesFetch, derived from the columns each statement really
returns — see
The listing and the fetch must be the same bag).
An unequal pair stays legal and stays correct; it costs a fetch per candidate per TTL
window instead of one per enumeration, silently. If one SQL-backed type's enumeration
is slower than its sibling's, compare the two SELECT lists first.
Query applies Filter.Fields with provider.MatchFields, in Go — the
predicates are never templated into the developer's SQL. Comparison in
the contract is typed ("5" != 5) and matches
collections by membership, which is the rules engine's own semantics; Postgres
would happily coerce '5' to 5, so a predicate rendered into SQL would answer a
different question than the rule evaluated over the same field. The cost is
honest: the whole object-type is materialised per enumeration, and the Registry's
per-type TTL cache is what absorbs it.
Rows stream, and the limit stops the read. One consequence worth knowing: a
Limit that truncates before a malformed row is reached will not surface that
row's error — the same enumeration with a larger limit can fail where the
bounded one succeeded. rows.Err() is checked unconditionally after the loop, so
a connection that dies mid-result is never reported as a short but successful
enumeration.
Timeouts and errors
Every statement runs under context.WithTimeout — DefaultTimeout is 5s —
applied on top of the caller's own context, so the earlier deadline wins. A
Fetch sits under Check, which owes a p99 under a millisecond; an unbounded
query against a host database is not a slow decision but one that never returns.
There is no "no timeout" setting.
Failures are APERTURE_SQL_PROVIDER_QUERY (driver or connection, wrapping the
cause), _AMBIGUOUS, _SCAN, _ROW_IDENTITY, and — for the declarative wiring
— _DSN_LITERAL and _CONNECTION. Every diagnostic names the developer's own
inputs (the column, the object-type, the row's position, the identity) and never
a row value, which is host data belonging to some account. An error already
carrying an APERTURE_* code — one a host's wrapping Querier raised — passes
through verbatim.
The Querier seam
The dependency is not a *sql.DB but a two-method interface:
type Querier interface {
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
}
A *sql.DB satisfies it, and so does an *sql.Conn, an *sqlx.DB, a pgx stdlib
handle, a *sql.Tx, or a host's own tracing, retrying, or read-replica wrapper.
On this path Aperture owns no connection lifecycle: it does not open, close,
ping, pool, or configure anything. That is also why sqlprovider imports no
driver — its dependencies are database/sql, errors, identity, and
provider — so a host that never uses it pays nothing for it, and a host that
does picks its own driver.
The seed package is the one place Aperture
links a driver itself: github.com/jackc/pgx/v5/stdlib, through database/sql,
Postgres only. It was chosen over lib/pq on a correctness argument despite
costing far more binary — lib/pq returns []byte for numeric and uuid,
which the value model cannot distinguish from jsonb, so a numeric of 1.50
would silently arrive as the float 1.5. Measured with the project's own build
flags, lib/pq costs +96,432 bytes (+0.34%) and pgx +3,589,088 bytes (+12.5%);
the whole SQL-provider epic took the stripped binary from 28,621,090 to
32,867,698 bytes (+14.8%). Both are pure Go, so CGO_ENABLED=0 holds.
For the full reference — the connection defaults, the pool-sharing rule, and the
gated real-Postgres test — see the sql-provider skill document.
In-memory objects: provider.Static
Not every object set comes from a file. A seed document
declares metadata inline, a test needs three objects and no fixture on disk, and
an embedded demo has its data compiled in. provider.Static is the
ObjectProvider for all three — the same semantics as csvprovider over a slice
already in memory, so nothing has to be re-derived per caller:
p, err := provider.NewStatic([]provider.Object{
{ID: identity.MustParse("account:acme/brand:1"),
Metadata: provider.Metadata{"tier": "gold", "tags": []any{"premium"}}},
{ID: identity.MustParse("account:acme/brand:2"),
Metadata: provider.Metadata{"tier": "silver"}},
})
reg.MustRegister("brand", p, provider.WithTTL(0))
It is immutable after construction, which is what makes it safe for concurrent use with no lock and makes the read-only contract trivially true: there is no reload that could edit a map the Registry already cached.
Everything is checked at construction, so a Fetch/List/Query can never fail
for a reason the caller could have been told about at wiring time. An empty or
duplicate identity is APERTURE_PROVIDER_INVALID (a last-writer-wins duplicate is
how one object's metadata becomes another's); metadata violating the
value model is APERTURE_METADATA_INVALID naming the
id, the field, and the offending path. Static does not re-implement the model —
and it does not trust a caller that says it already validated.
Fetch returns APERTURE_NOT_FOUND for an undeclared id, List returns
declaration order, and Query hands Filter.Fields to provider.MatchFields
while honouring Pattern and Limit directly — the same contract
every other provider implements.
Values are deep-copied in at construction and handed out by reference on every read. The copy is what makes the read-only contract hold against a caller that keeps its input: mutating those maps afterwards, at any depth, cannot reach metadata the Registry has already cached. Nothing is copied on the read path, which is the allocation-aware half of the same contract.
Where this leads
Providers feed three consumers documented elsewhere: the object metadata a
rule reads, the object enumeration an
implicit/exclusive scope performs, and — through the attribute seam
— the principal.* and account.* roots the same rule
reads about the asker. For the CLI that inspects registered providers, see the
provisioning commands; for the one that inspects
attribute slots and drops cached bags, see aperture attributes.
The RBAC domain model
Every authorization decision Aperture makes is resolved against a small,
explicit graph of entities. They all live in the model package — the single
place the domain is defined, and the package every storage backend implements
through one Storage interface. The model couples only to the leaf packages
errors/ and identity/; it holds no storage, engine, or transport concepts,
so this graph is the same whether you drive it from the library,
the CLI, Twirp, or MCP.
An account is a model entity, not a package of its own — tenancy is part
of the domain, documented alongside the rest here.
The entities
| Entity | What it is |
|---|---|
| ObjectType | A protected resource type (e.g. document) with a declared, closed set of action verbs. |
| Permission | An (action, scope-strategy) pair bound to one object type; optionally Delegatable. |
| Principal | A user or machine, addressable by the identity scheme; carries its assigned RoleIDs. |
| Role | A named bundle of permissions; a principal is assigned roles, and a role may also be a grant subject. |
| Group | A collection of principals that can itself hold grants (be a grant subject). |
| Account | A first-class tenancy boundary: the unit a grant is stamped to and the context a decision is scoped to. |
| Membership | The edge linking a global principal to an account it belongs to. |
| Grant | Binds a subject to a permission, scoped to an object pattern and an effect, stamped to an account. |
| Rule | A named, persisted rule AST (see Rules) a scope strategy can reference. |
| Template | A named, versioned bundle of parameterized grants for fast, consistent provisioning. |
How they relate
erDiagram
ObjectType ||--o{ Permission : "declares verbs for"
Permission }o--|| ScopeStrategy : "references"
Role }o--o{ Permission : "bundles"
Principal }o--o{ Role : "assigned"
Group }o--o{ Principal : "contains"
Account ||--o{ Membership : "has"
Principal ||--o{ Membership : "in"
Account ||--o{ Grant : "stamps"
Grant }o--|| Permission : "on"
Grant }o--|| Subject : "for"
Subject }o--|| Principal : "or"
Subject }o--|| Role : "or"
Subject }o--|| Group : "binds"
A Subject is not a stored row of its own — it is a {Kind, ID} value
carried on the grant, where Kind is one of principal, role, or group.
At decision time the engine expands the request's principal into a subject
set — the principal itself, the roles on Principal.RoleIDs, and the groups
that list the principal — and resolves grants against that whole set.
Object types and typed actions
An ObjectType names an identity-segment type and declares a closed verb
set in Actions. The verb set is authoritative: a Permission may only name
an action the object type declares. Declaring a permission against an
undeclared verb is rejected with APERTURE_ACTION_UNDECLARED. This
typed-action validation means a typo in an action never silently becomes an
unmatchable permission — it fails at write time.
{ "name": "document", "actions": ["read", "write", "delete", "aperture.delegate"] }
An object type opts a resource into delegation or
impersonation by declaring their reserved verbs
(aperture.delegate, aperture.impersonate.augment,
aperture.impersonate.become) and a permission on each.
Permissions
A Permission is an (action, scope-strategy) pair bound to one object type.
The ScopeStrategy field is an opaque reference resolved by the scope
layer — it decides which objects within a grant's pattern the permission
reaches. The Delegatable flag is an opt-in, fail-closed gate: a
permission cannot be handed to another principal via bestow
until it is explicitly flagged, so the right to delegate never leaks to
permissions whose authors did not intend it.
Grants: subject × permission × object × effect × account
The Grant is the binding that actually confers (or withholds) access. It is
the load-bearing entity, so its shape is worth reading closely:
- Subject — what the grant applies to: a principal, role, or group.
- PermissionID — the granted permission (which fixes the action and scope strategy).
- Object — an identity pattern in string form, e.g.
account:acme/project:atlas/**. Wildcards and explicit id-sets (brand:{1,5,23}) are first-class, so one grant can scope broadly or to a handful of ids without many grants. The engine parses it withidentity.ParsePattern. - Effect —
allowordeny. At decision time a matching deny overrides allows at equal-or-broader specificity, with a specificity tiebreak resolved by the pattern. - AccountID — mandatory. It stamps the grant to one account.
Accounts and the isolation invariant
Accounts are global entities, and a single principal can belong to more than
one (via Membership). The core tenancy rule is the (principal,
active-account) isolation invariant: a principal's grants in one account
never apply in another, because every grant query is account-scoped. A
Membership edge says only that a principal is in scope in an account — not
what it may do there (that is grants); the engine can optionally deny a request
whose principal is not a member of the active account, as defence in depth.
The lone, deliberate exception is the account wildcard *
(model.AccountWildcard): a grant stamped to * is loaded for decisions in
every account. It is not a real account — ValidateAccount rejects * as an
account id so no row can shadow the wildcard — and only a system-tier admin can
mint one.
Because grants are account-stamped and queries account-scoped, error messages and decision output must never surface another account's data. Keep examples single-account.
Templates: provisioning many grants at once
A Template is a named, versioned bundle of parameterized grants. It
declares typed Params (each a segment — an identity component — or a free
string) and a set of TemplateGrants whose subject ids and object patterns
reference those params with ${name} tokens. At apply time the params are
filled with concrete values, tokens are substituted, and the bundle expands
to a set of concrete Grants stamped to a target account and applied
transactionally.
- A template is identified by the
(Name, Version)pair; storing a new version keeps older ones intact, so an apply can pin a version while new provisioning uses the latest. - Expansion is all-or-nothing: a missing, unknown, or ill-typed param — or any expanded grant that fails validation — aborts with no grants written, so a partial expansion can never be applied.
- The default grant-id prefix is
<name>-v<version>, so re-applying with the same prefix upserts (idempotent provisioning) rather than duplicating.
Apply a template from the CLI; the same expansion runs behind the Twirp and library surfaces.
Rules
A Rule is a named, persisted decision AST stored verbatim as canonical JSON —
the exact serialization the rules engine marshals. The model layer
keeps it opaque (a json.RawMessage) so it stays free of an engine dependency;
it validates only that the name is non-empty and the AST is a JSON object.
Type-checking and compilation are the rules engine's job. A scope
strategy names a rule to make membership rule-driven.
Validation and persistence
Every entity has a Validate* function that runs before persistence — non-empty
ids, valid enum values, parseable identities and patterns, typed-action checks.
Structural failures are APERTURE_INVALID_INPUT; the specialized codes
(APERTURE_ACTION_UNDECLARED, APERTURE_TEMPLATE_INVALID,
APERTURE_RULE_INVALID, …) name the specific rule that was broken. The
Storage interface is the single seam behind which the in-memory and SQLite
backends live; validation guarantees a malformed entity never reaches the
decision hot path.
Where this leads
- Mutate these entities from the shell: CLI mutations.
- Grant a subset of your authority to someone else: Delegation.
- Borrow another principal's authority: Impersonation.
- Turn a grant's pattern into a concrete object set: Scopes.
- Resolve a request into a verdict: the Decision API.
Delegation ("bestow")
Delegation lets a principal hand a slice of the authority it already holds to
another principal — without privilege escalation and without crossing an account
boundary. Aperture calls the grant-forward operation bestow and its inverse
revoke. The code lives in the delegation package; drive it from the CLI
with bestow / revoke.
The defining property: a bestowed grant is an ordinary account-scoped grant. There is no special storage and no special decision path — once bestowed, the engine treats it exactly like any other grant, and it vanishes outside its account like any other grant, because every grant query is account-scoped.
Delegation is itself a permission
There is no magic "admin can delegate" flag. The right to delegate is a normal
grant on a normal permission whose action is the reserved verb
aperture.delegate (delegation.DelegateAction). A principal "may delegate"
within an account when its effective grant set holds an allow grant on that
action whose object pattern covers the object being bestowed. So the right
to delegate is scoped exactly like every other permission — a delegate grant
over account:acme/project:atlas/** authorizes bestowing only within that
subtree.
An object type opts a resource into delegation by declaring the
aperture.delegate verb and defining a permission on it.
The bestow rule
Bestow is conjunctive and fail-closed. A delegator may bestow a grant G
only when all four conditions hold, checked against the delegator's
(delegator, account) effective grant set:
flowchart TD
B["Bestow(delegator, G)"] --> M{"delegator is a member<br/>of G's account?"}
M -->|no| D1["DENIED: cross_account"]
M -->|yes| Del{"may-delegate right<br/>covers G's object?"}
Del -->|no| D2["DENIED: no_delegate_right"]
Del -->|yes| Flag{"G's permission<br/>is Delegatable?"}
Flag -->|no| D3["NOT_DELEGATABLE"]
Flag -->|yes| Sub{"delegator holds an allow<br/>with G's action + scope strategy<br/>whose object ⊇ G's object?"}
Sub -->|no| D4["DENIED: not_subset"]
Sub -->|yes| OK["grant written<br/>as an ordinary grant"]
- Account membership — the delegator is a member of
G's account. A bestow stamped to any other account is rejected up front (a cross-account leakage guard, and defence in depth over the already account-scoped grant query). - May-delegate — the delegator holds an effective allow grant on
aperture.delegatewhose object pattern coversG's object. - Delegatable —
G's underlying permission is flaggedDelegatable. This is the model's opt-in gate; a permission is non-delegatable until explicitly flagged. - Subset — the delegator holds an effective allow grant with the same
action and scope strategy as
G's permission, whose object pattern coversG's object. In other words,Gmust be a subset of the delegator's own authority — never broader.
Only allow grants may be bestowed; a delegated deny is out of scope. Any
failure returns an APERTURE_DELEGATION_* coded error naming the first broken
condition (APERTURE_DELEGATION_DENIED with a reason, or
APERTURE_DELEGATION_NOT_DELEGATABLE) and writes nothing.
"Covers" is pattern containment
"⊇" throughout is identity.Contains: the target's object pattern must be
equal-or-more-specific than — that is, contained within — the delegator's. A
more-specific pattern under the delegator's authority is a subset; a
broader-or-disjoint one is not. The containment test is conservative (sound,
possibly incomplete): when authority cannot be proven, the bestow is denied. A
delegator holding a deny grant confers nothing to hand on — only allow grants
are considered when computing what the delegator may bestow.
Revoke
Revoke is the inverse mutation and is gated by the same authority check
Bestow applies. A delegator may revoke only a grant it could itself bestow
now — so revocation can never be used to reach across accounts or beyond the
delegator's own scope. Revoking an unknown grant is APERTURE_NOT_FOUND.
Worked example
Alice holds two allow grants in account acme: a delegate right and a read
right, both over account:acme/project:atlas/**, on a Delegatable
read permission. She bestows a narrower read grant on Bob:
export APERTURE_PRINCIPAL=alice
bin/aperture bestow --delegator alice --json '{
"id": "g-bob-read-42",
"accountId": "acme",
"subject": {"kind": "principal", "id": "bob"},
"permissionId": "perm-doc-read",
"object": "account:acme/project:atlas/document:42",
"effect": "allow"
}'
This passes all four checks: Alice is a member of acme; her delegate right
covers document:42; perm-doc-read is delegatable; and her own read grant
over .../atlas/** contains the narrower .../atlas/document:42. Had Bob's
object been account:acme/project:atlas/** (equal to or broader than Alice's),
the subset check would deny it.
Related
- The RBAC model — grants, permissions, the
Delegatableflag. - Identity patterns — how containment and specificity are decided.
- CLI mutations — the
bestow/revokecommands. - The service facade — how surfaces reach the gated delegation service.
Impersonation
Impersonation lets a privileged operator borrow another principal's authority
for a decision — to reproduce what a user sees, or to act on their behalf — under
hard guardrails and a strict time-box. This chapter is the conceptual half:
what the two modes mean, how a session is gated, and how it expires. For the
engine API that consumes a session (CheckAs / EnumerateAs / ExplainAs and
the ImpersonationContext shape), see the library reference.
The feature has two halves. The impersonation package starts and gates a
session; the engine's *As entry points consume it. A session never mutates
stored grants — it only steers which subject set a decision resolves over.
Two modes: augment vs become
flowchart LR
subgraph augment["AUGMENT"]
A1["operator subjects"] --> AU["∪"]
A2["target subjects"] --> AU
AU --> AR["decision resolves<br/>over the union"]
end
subgraph become["BECOME"]
B1["target subjects<br/>alone"] --> BR["decision resolves<br/>as if the target asked"]
end
- Augment adds the target's effective permissions to the operator's own. The operator keeps acting under its own identity, and the decision resolves over the union of both subject sets. Use it to "see what they can see" while retaining your own authority.
- Become fully assumes the target's identity: the decision resolves over the target's subject set alone, as if the target had asked. The operator's own grants do not apply.
Become is strictly stronger than augment, and that ordering drives the gating rule below.
The mode also decides whose attributes a rule reads. A rule-backed
scope strategy evaluates against the effective subject: under
become that is the target — principal.id is the target's id and the target's
kind picks the attribute slot principal.tier and friends come from — and under
augment it is the operator, which is what "keeps acting under its own identity"
means. The grant set and the rule always describe the same principal; a decision
that resolved the target's grants while reading the operator's attributes would
be an authorization bug invisible in a trace. This does not touch the audit
trail: the real operator is recorded either way (see below). Under become,
principal.id therefore means the target — a deliberate change in v0.8.0.
Impersonation is a permission — and become needs the stronger right
Like delegation, impersonation is gated by ordinary grants on reserved action verbs. Two verbs gate the two modes:
| Mode | Reserved verb | Constant |
|---|---|---|
| Augment | aperture.impersonate.augment | impersonation.AugmentAction |
| Become | aperture.impersonate.become | impersonation.BecomeAction |
An operator "may impersonate" a target when its effective allow grant set
holds the mode's right whose object pattern covers the target principal's
identity (identity.Contains). The strictly-stronger rule:
- Become requires an allow on
aperture.impersonate.becomecovering the target. - Augment requires an allow on
aperture.impersonate.augmentoraperture.impersonate.becomecovering the target — so holding the become right implies the augment right (a become-holder can do either), but the augment right alone can never become a target.
An object type opts a principal type into being impersonable by declaring these verbs and defining a permission on each.
Starting a session: the guardrails
Start(operator, target, account, mode) gates and issues a session. The
guardrails are conjunctive and fail closed — every one must hold, checked
against the operator's (operator, account) effective grants:
- Account boundary — both the operator and the target are members of the active account. A session spanning accounts is refused; there is no cross-account impersonation. (The engine independently re-checks this on every decision, so a forged context cannot bypass it.)
- Right held — an effective allow grant on the mode's action (or, for augment, the stronger become action) whose object pattern covers the target's identity.
A failure returns APERTURE_IMPERSONATION_DENIED naming the failed guard in the
error context (operator_not_member, cross_account, no_become_right,
no_augment_right). Empty inputs or an unknown mode are APERTURE_INVALID_INPUT.
Expiry: a hard, short time-box
Impersonation is a privileged, transient act, so every session carries an
expiry. Start stamps ExpiresAt = now + ttl, where ttl defaults to
DefaultTTL — 15 minutes — and can be overridden per service with
WithTTL (a non-positive ttl is ignored, so a session is always bounded). The
clock is injected, so tests never touch the wall clock.
The ExpiresAt instant travels with the session into the engine, so the
engine enforces the time-box itself:
- A session presented after its expiry confers no elevation. Instead of the target's authority, the decision fails closed to the operator's own authority — an expired become never resolves as the target. Elevation never outlives its time-box.
- A surface that would rather reject an expired session up front — instead of
silently resolving with no elevation — calls
Session.Live(now), which returnsAPERTURE_IMPERSONATION_EXPIREDonce the session is past its expiry.
Every resulting decision records both the real operator and the effective subject for the audit layer — impersonation is never silent.
Starting one from the CLI
bin/aperture impersonate --operator root --target alice \
--account acme --mode augment
This prints the session (operator, target, account, mode, and expiry) as JSON.
The command is guarded exactly as above: an operator with no right covering
alice is denied with APERTURE_IMPERSONATION_DENIED. See
CLI mutations.
Related
- Library: Impersonation — the
*Asengine API and theImpersonationContextdecorator this conceptual page complements. - The RBAC model — subjects, grants, and reserved action verbs.
- Delegation — the sibling "grant a subset of your authority" feature, gated the same way.
- The service facade — how surfaces reach the gated impersonation service.
Filtering entity lists
The filter package applies server-side field predicates to a list of
entity JSON bodies before they are returned to a client. It is the business
logic behind the admin UI's data-grid filters: the client sends a Spec (a set
of predicates), the server evaluates it against each entity, and only the
matches cross the wire. Rows filtered out are never sent — the filter runs where
the data lives, not in the browser.
Why it is dynamic, not per-type
Aperture's entities are heterogeneous — accounts, principals, roles, grants, and
so on. Rather than a typed filter per entity, filter addresses fields by their
JSON key and evaluates over the decoded map[string]any. One predicate
engine works across every entity list. Two consequences fall out of this:
- Text comparisons are case-insensitive. Both the field value and the predicate value are lower-cased before comparing.
- Array fields match if any element satisfies the predicate. A principal's
rolesarray matchesroles contains adminwhen any single role does. JSON numbers and booleans are rendered to strings for the comparison (an integer prints without a trailing.0).
Predicates and the spec
A Predicate is one field test — a Field (JSON key), an Op, and a Value:
type Predicate struct {
Field string
Op string
Value string
}
type Spec struct {
Predicates []Predicate
MatchAny bool // OR when true; AND (the default) when false
}
A Spec combines its predicates with AND by default, or OR when
MatchAny is set. Spec.Empty() reports whether the spec would constrain
nothing, so a caller can skip the work entirely.
The operators
The operator set is deliberately small — "core":
| Op | Constant | Matches when the field… |
|---|---|---|
eq | OpEq | equals the value (case-insensitive) |
contains | OpContains | contains the value as a substring (case-insensitive) |
starts | OpStarts | starts with the value as a prefix (case-insensitive) |
empty | OpEmpty | is empty or absent — the value is ignored |
For empty, a field counts as empty when it is absent, null, an empty string,
or an empty array. For the three value operators, a predicate with an empty
Value is treated as unusable and ignored. An unknown operator also
makes its predicate a no-op — it is simply skipped, never an error.
Evaluation and the fail-open rule
Apply(entities, spec) returns the subset of entities (each a JSON object
body) that satisfy the spec:
- An empty spec — no usable predicates — returns the input unchanged.
- Usable predicates are collected; then each entity is decoded and tested,
combining predicate results with AND or OR per
MatchAny. - An entity whose JSON does not decode to an object is kept. This is the deliberate fail-open rule: a filter must never silently hide a row it could not evaluate. (Contrast the decision engine, which fails closed; a display filter is not an authorization boundary, so it errs toward showing data rather than hiding it.)
matches := filter.Apply(bodies, filter.Spec{
Predicates: []filter.Predicate{
{Field: "kind", Op: filter.OpEq, Value: "user"},
{Field: "roles", Op: filter.OpContains, Value: "analyst"},
},
// MatchAny false → both must hold (AND).
})
// matches holds only the user principals that carry an "analyst" role.
Scope note
filter narrows a list the caller is already authorized to see — it is a
display convenience, not an access-control gate. The authorization decision that
determines which entities a caller may list at all is made upstream by the
decision engine; a grant's own object membership is
decided by scope strategies. Because the lists a filter runs over
are account-scoped upstream, filtering never widens visibility across the
account isolation boundary.
Related
- The RBAC model — the entities whose JSON bodies are filtered.
- Scopes & scope strategies — the authorization-side "which objects" question, distinct from display filtering.
- Admin UI shell — the data-grid this filtering backs.
Authentication
The auth package turns an incoming external credential into a known
Aperture principal. Authentication is always external: Aperture consumes
credentials, it never issues them. There is no login, signup, or
credential-issuance surface — a caller arrives already holding a bearer token,
and auth decides who that token names.
The Authenticator seam
Every adapter implements one small interface:
type Authenticator interface {
Authenticate(ctx context.Context, bearer string) (principalID string, claims Claims, err error)
}
The input is the raw bearer token — the value after Bearer in the
Authorization header. The HTTP middleware in internal/server extracts it from
the request and calls Authenticate; on success it attaches a Principal to the
request context. Handlers (and the decision surface) recover the caller with
PrincipalFromContext.
type Principal struct {
ID string // the resolved Aperture principal id
Claims Claims // the verified assertions behind it (empty for dev)
}
Claims is always a generic map[string]any, keyed by claim name, so downstream
code reads any claim uniformly regardless of which adapter produced it.
Every adapter fails closed. A missing, malformed, or unverifiable credential is an error, never a silently-empty principal:
- an empty or unresolvable credential →
APERTURE_UNAUTHENTICATED - a presented credential that fails verification →
APERTURE_INVALID_TOKEN
An anonymous request (no credential at all) surfaces as ok == false from
PrincipalFromContext; callers that require a principal treat that as
APERTURE_UNAUTHENTICATED.
The claim → principal mapping
The two verifying adapters (OIDC and Parsec) resolve the principal id through the
same configurable mapping: PrincipalClaim names which verified claim — sub,
email, or a custom claim — becomes the Aperture principal id. Which claim is the
principal is configuration, not code. It defaults to sub when unset.
A verified token that does not carry the configured claim (or carries it empty, or
non-string) is APERTURE_UNAUTHENTICATED — the token verified, but it does not
name a principal Aperture can use.
The dev adapter is the one exception by construction: its bearer is the principal, so there is no claim to map.
The three adapters
dev / static (NewDev)
Trusts any non-empty bearer as the principal id — Authorization: Bearer alice
resolves to principal alice. It performs no verification and returns nil claims.
This is the adapter that makes Aperture runnable with no external IdP: fixtures,
demos, and CI. It is the default when no auth mode is configured.
It is a development / single-tenant trust shortcut, not a security boundary. Never select it for a deployment facing untrusted callers. Even so, it still fails closed on an empty bearer.
oidc / JWT (NewOIDC)
Verifies a bearer JWT against an OIDC provider's published keys using the pure-Go
github.com/coreos/go-oidc verifier: signature, issuer (iss must equal
Issuer), audience (aud must contain Audience), and expiry. Signing keys are
established up front — by JWKS discovery against Issuer, or from an explicit
JWKSURL when the provider has no discovery document — so per-request
verification is offline against the cached key set.
Issuer and Audience are both required: an empty audience would accept tokens
minted for any client. Discovery performs network I/O, so Build/NewOIDC take a
ctx. A missing issuer or audience is APERTURE_CONFIG_INVALID; a discovery
failure at startup is APERTURE_BOOT.
parsec (NewParsec)
Verifies a token minted by the in-house github.com/frankbardon/parsec token
broker against the broker's signing keyring. This is Orbit's realtime
token-broker pattern: the broker issues a client a short-lived access token, and
Aperture loads the same keyring to verify that token and learn who the caller is.
The keyring is the integration seam — the broker and Aperture must share it,
because a parsec key carries a per-key id (kid) the verifier matches the token
against. Point Aperture at the broker's persisted keyring exactly one of two ways
(KeyringPath wins):
KeyringPath— the broker'skeyring.jsondirectly.StateDir— the broker's state directory, inside whichkeyring.jsonis resolved (the Orbitserve.goshape,StateDir = <DataDir>/parsec).
The verifier follows the ring by reference, so a broker key rotation that rewrites
the ring takes effect without restarting Aperture. By default the adapter
accepts the parsec access token type — the connection token a caller presents —
and maps the sub claim (which parsec stamps with the user id) to the principal.
A missing keyring source is APERTURE_CONFIG_INVALID; a load failure is
APERTURE_BOOT; a verification failure (bad signature, wrong type, expired) is
APERTURE_INVALID_TOKEN.
Parsec is an in-house credential source. This page documents only what the
auth/parsec.goadapter exposes — the keyring-verification seam. The broker's own token-minting protocol lives in theparsecmodule, not here.
Selecting an adapter: config
Which adapter is built is chosen by configuration through auth.Config, whose
zero value selects the dev adapter — the documented default that makes Aperture
runnable with no IdP. Config.Build(ctx) returns the matching Authenticator; an
unrecognised mode is APERTURE_CONFIG_INVALID.
ConfigFromEnv reads the configuration from APERTURE_* environment variables:
| Env var | Config field | Applies to | Notes |
|---|---|---|---|
APERTURE_AUTH_MODE | Mode | all | dev | oidc | parsec; empty ⇒ dev |
APERTURE_AUTH_PRINCIPAL_CLAIM | PrincipalClaim | oidc, parsec | empty ⇒ sub |
APERTURE_OIDC_ISSUER | OIDCIssuer | oidc | required (issuer URL + discovery root) |
APERTURE_OIDC_AUDIENCE | OIDCAudience | oidc | required (Aperture's client id at the IdP) |
APERTURE_OIDC_JWKS_URL | OIDCJWKSURL | oidc | optional; set to skip discovery |
APERTURE_PARSEC_KEYRING | ParsecKeyringPath | parsec | path to keyring.json (wins over state dir) |
APERTURE_PARSEC_STATE_DIR | ParsecStateDir | parsec | broker state dir; keyring.json resolved inside |
# OIDC against an IdP with a discovery document.
export APERTURE_AUTH_MODE=oidc
export APERTURE_OIDC_ISSUER=https://accounts.example.com
export APERTURE_OIDC_AUDIENCE=aperture
export APERTURE_AUTH_PRINCIPAL_CLAIM=email
Related
- The RBAC model — the principal an authenticated credential resolves to.
- Audit trail — where an authenticated actor's actions are recorded.
- The authz gate — how administrative authority is enforced on the resolved principal.
- Error codes —
APERTURE_UNAUTHENTICATED,APERTURE_INVALID_TOKEN,APERTURE_CONFIG_INVALID,APERTURE_BOOT.
Audit trail
Aperture keeps an append-only audit trail (FR-25). The trail is weighted toward safety-critical events without sinking the decision hot path, so it records under two disciplines depending on the event class.
Two recording disciplines
Always + synchronous. Every mutation, every impersonation event, and every delegation is recorded the moment it happens, reliably, on the calling goroutine. Mutations are not the hot path, so a synchronous, durable write is the right trade — a caller can even surface a failed mutation-audit write if it chooses.
Sampled + asynchronous. Decision checks (Check / Enumerate / Explain)
are the hot path. They are recorded only when the configured Sampler keeps them,
and the keep is handed to a background writer over a buffered channel. The
decision never blocks on the audit write: if the buffer is full the event is
dropped best-effort, so an audit backlog can never regress the decision NFR.
rec := audit.New(store,
audit.WithSampleRate(0.01), // keep ~1% of decisions
audit.WithBuffer(4096),
)
defer rec.Close() // flushes buffered decision events; call on shutdown
The Recorder owns one background writer goroutine, is safe for concurrent use,
and is drained deterministically by Close. The background writer runs detached
from any request context, so a cancelled request never aborts an in-flight audit
write.
| Method | Discipline | Notes |
|---|---|---|
Record(ctx, ev) | always, synchronous | stamps id + timestamp, writes through the sink, returns the storage error |
RecordDecision(ctx, fn) | sampled, asynchronous | invokes fn only on a keep, so an un-sampled decision pays nothing but the Sampler call; returns whether it was sampled |
Close() | — | idempotent; flushes the buffer, waits for the writer. After Close, RecordDecision is a no-op; Record still writes |
Sampling and determinism
The Sampler and the clock are injected, so tests are not flaky — inject a
deterministic sampler (e.g. keep 1-in-N via SamplerFunc) and a fixed clock
rather than relying on wall-clock time or an unseeded global rand. Production uses
WithSampleRate, a probabilistic sampler over math/rand/v2. With no sampling
option, decision audit is off (rate 0) while always-on events are still
recorded.
Construction options:
| Option | Effect |
|---|---|
WithSampleRate(r) | probabilistic decision sampler, r clamped to [0,1] (0 disables decision audit, 1 records every decision) |
WithSampler(s) | explicit Sampler (takes precedence over WithSampleRate; used in tests) |
WithBuffer(n) | async writer buffer capacity (default 1024) |
WithClock(fn) | override the timestamp clock (default time.Now) |
WithIDFunc(fn) | override the event-id generator (default: random hex) |
WithErrorHandler(fn) | observability callback for async write failures and buffer-overflow drops; never affects the decision path |
The event shape
The Recorder persists through a Sink (AppendAudit(ctx, ev)), which
model.Storage satisfies. Each entry is a model.AuditEvent — a public
contract the audit viewer reads, so its field set is additive-only:
| Field | Meaning |
|---|---|
ID, Timestamp | assigned by the recorder |
EventType | broad category the query filters on: mutation, decision, impersonation, delegation |
Action | the specific operation, e.g. PutGrant, Check, Bestow, ImpersonationStart |
Actor | the principal that really acted |
EffectiveSubject | the target whose authority was borrowed under impersonation (empty otherwise) |
ImpersonationMode | augment or become under an impersonation session (empty otherwise) |
Account | the active account the event was scoped to |
Target | the entity, object, or resource the event concerns |
Outcome | allow/deny for a decision, success/failure for a mutation/impersonation/delegation |
Reason | human-readable explanation (deciding grants, or the failure cause) |
Details | optional structured blob backends persist as JSON |
A record made under impersonation carries both the real actor and the effective subject, so an impersonated action is never mis-attributed to the target alone.
Append-only, at the storage layer
The trail is append-only by contract in model.Storage, not by convention:
AppendAudit— the only single-event write.QueryAudit(filter)— reads events matching anAuditFilter(actor, account, event type, outcome, time bounds, limit — each optional, ANDed together), returned newest-first. A zero filter returns the whole trail.PruneAudit(policy)— the only delete, and only in bulk: retention pruning by age (Before) and/or size (MaxCount), returning the count removed.
There is no update and no single-event delete, so a recorded event cannot be
silently altered. Backend failures surface as APERTURE_STORAGE.
Related
- The authz gate — the gate that guards the mutations this trail records.
- Impersonation — why events carry both a real actor and an effective subject.
- Delegation ("bestow") — always-recorded bestow/revoke events.
- Storage — the
Storagebackend the trail is persisted through.
The authz gate
The authz package is the authorization gate the model-mutation API calls
before every mutation. It expresses Aperture's internal administrative authority —
the right to change the system itself — and enforces the required authority on
each mutation (FR-17).
Authority is in-scheme, not a parallel system
The gate's defining property: an admin right is just an ordinary grant. It is
an allow grant on a reserved admin action verb, aperture.admin, whose object
pattern covers a tier's authority identity. The gate decides authority by
resolving that grant through the same engine that answers every other question
— engine.Check / engine.Explain — so an admin check is an ordinary decision:
wildcard-resolvable, auditable, and explainable, with no special-cased bypass
path.
This follows the same idiom as delegation (aperture.delegate) and impersonation
(aperture.impersonate.*): a reserved action verb plus an identity-pattern-scoped
grant.
Two tiers
| Tier | Authority anchor | Governs | Holder |
|---|---|---|---|
| System | system:schema (spelled system:* as a grant) | the global schema — object-types, permission types, roles, groups, principals, providers, templates, rules — plus tenancy (accounts) | anyone whose effective allow grants in their active account include an allow on aperture.admin covering system:* (or the all-covering **) |
| Account | account:<acct>/admin:all (spelled account:<acct>/admin:*) | grants and delegation within one account only | anyone whose effective allow grants in that account include an allow on aperture.admin covering account:<acct>/admin:* |
Account-admin authority is confined to its own account. Because an
account-tier check resolves against the target account, an account-admin of
account A is refused any mutation scoped to account B — A's grants are not even
loaded for B, and account:A/admin:* does not cover account:B/admin:*.
System supersedes account. A system-admin may drive any account-tier mutation
in any account — including a freshly-created account that holds no admin grants of
its own yet. Authorize checks system-admin first for account-tier mutations and
only falls back to the per-account check for non-system actors (so its richer
denial context surfaces).
On the address spelling. The identity grammar accepts
**only as a standalone path segment, not inside an id component, so the in-scheme spelling of a tier's authority is the single-component wildcard the grammar supports:system:*andaccount:<acct>/admin:*. A broader holder (account:acme/**, or the all-covering**) still resolves, so the authority is genuinely wildcard-resolvable. A principal wanting both tiers at once holds an allow onaperture.adminover**, which covers every tier anchor.
The mutation → tier policy lives in one place
Mutation is the key the mutation API passes to the gate, so the gate — not each
endpoint — owns the mutation→tier policy. A single map, mutationTier, is the
authoritative source of truth:
- System tier:
put_object_type,put_permission,put_role,put_group,put_principal,put_provider,put_template,put_rule,put_account(and theirdelete_*pairs), plusimport— applying a whole declarative state file, the most privileged mutation there is. - Account tier:
put_grant/delete_grant,bestow/revoke(delegation), andput_membership/delete_membership.
TierOf(m) reports a mutation's tier and whether it is known. An unknown
mutation fails closed — the gate refuses an operation it has no policy for.
The API
gate := authz.NewGate(eng) // holds only the decision engine
// Enforce the tier a mutation requires. For an account-tier mutation, account
// is the target account (e.g. the grant's AccountID) and is mandatory.
err := gate.Authorize(ctx, authz.Actor{Principal: "alice", Account: "acme"},
authz.MutationPutGrant, "acme")
| Method | Returns nil when… | Otherwise |
|---|---|---|
Authorize(ctx, actor, m, account) | the actor holds the tier m requires | APERTURE_AUTHZ_DENIED; unknown mutation ⇒ APERTURE_INVALID_INPUT |
RequireSystemAdmin(ctx, actor) | the actor holds system-admin authority | APERTURE_AUTHZ_DENIED |
RequireAccountAdmin(ctx, actor, account) | the actor holds account-admin in the target account | APERTURE_AUTHZ_DENIED |
ExplainSystemAdmin(ctx, actor) | — | the engine Trace behind the system-admin decision |
ExplainAccountAdmin(ctx, actor, account) | — | the engine Trace behind the account-admin decision |
Actor is the principal id plus the active account it is operating in. The
Account is mandatory for a system-tier check (it is where the actor's system:*
grant is resolved); for an account-tier check the target account governs
instead, so confinement holds regardless of Actor.Account.
Because every authority question resolves through the normal engine,
ExplainSystemAdmin / ExplainAccountAdmin return a full derivation Trace
whose verdict matches the corresponding Require* — the admin check is
explainable on admin identities exactly like any other decision.
Related
- The decision engine — the
Check/Explainevery authority question resolves through. - Delegation ("bestow") — the
bestow/revokemutations this gate guards, and theaperture.delegateidiom it mirrors. - Impersonation — the
aperture.impersonate.*idiom. - Audit trail — where a gated mutation's outcome is recorded.
- Error codes —
APERTURE_AUTHZ_DENIED,APERTURE_INVALID_INPUT.
Seed & portability
The seed package loads a declarative authorization model — a single JSON or
YAML document — into a model.Storage, and exports one back out. It is both the
human-authored on-ramp behind the aperture check / aperture serve demo and the
full round-trip state file the model portability endpoints use.
One document, both directions
A Document is a flat list of each entity kind. The field tags cover both YAML
and JSON, so either format decodes into the same shape:
accounts: [{ id: acme, name: Acme }]
memberships: [{ principal: alice, account: acme }]
object_types: [{ name: document, actions: [read, write] }]
permissions: [{ id: doc.read, object_type: document, action: read, scope_strategy: implicit }]
principals: [{ id: alice, kind: user, roles: [reader] }]
roles: [{ id: reader, name: Reader, permissions: [doc.read] }]
groups: [ ... ]
grants: [{ id: g1, account: acme, subject: { kind: principal, id: alice }, permission: doc.read, object: "account:acme/document:*", effect: allow }]
templates: [ ... ]
rules: [ ... ]
connections: { ... } # named database connections — wiring, SHARED (names only)
providers: [ ... ] # object-metadata providers — wiring, SHARED
field_types: [ ... ] # declared types for metadata fields — wiring, SHARED
attribute_providers: [ ... ] # EXTERNAL sources for subject bags — wiring, SHARED
objects: [ ... ] # inline object metadata — wiring, LOCAL
attributes: [ ... ] # inline SUBJECT attributes (principal / account) — wiring, LOCAL
The first ten sections are model state and the last six are runtime wiring.
Of the six, four are shared — aperture wiring push writes them to the
database every instance of a deployment already holds — and two are local to the
instance whose file lists them. See
What is not in the file below, and
aperture wiring for the command surface.
connections: is a map keyed by name, not a list: the name is what a
provider entry's connection: refers to, and a map cannot declare one twice.
Every field mirrors its model counterpart in declarative form. The Document
started as a minimal seed shape and was generalized to the complete model, so an
export file is a strict superset of a seed file: a seed that omits
templates/rules/providers/objects/field_types/attributes/attribute_providers
loads unchanged, and a full export reloads through the very same path. The field
set is additive-only, so old seeds keep loading.
Rule ASTs are carried as raw JSON — exactly the rules package's canonical
Node serialization — so the file never invents a second rule format; it is the
same shape the node editor reads and writes.
Loading (import)
// From bytes, explicit format:
err := seed.Load(ctx, store, data, seed.FormatYAML)
// From a file — format inferred from the extension (.json ⇒ JSON, else YAML):
err := seed.LoadFile(ctx, store, "model.yaml")
Parse decodes the document; Apply upserts it into the store in dependency
order: accounts, object types, permissions, principals, memberships, roles,
groups, grants, templates, then rules. Each write goes through the storage layer's
own validation — a malformed entity surfaces the same coded error a programmatic
Put would (e.g. APERTURE_ACTION_UNDECLARED for a permission naming an
undeclared action). Rule ASTs are additionally validated against the rules
engine's contract before storing, so an import rejects a structurally broken rule
(APERTURE_RULE_INVALID) rather than persisting one the engine could never
compile.
Applyis not transactional — a failure may leave a partial model. This is acceptable for the seed-and-demo use case. (The mutation API's bulk endpoints useStorage.Atomicwhen all-or-nothing is required.)
The YAML path routes through JSON internally (yaml → generic → json → Document)
so the raw-JSON rule AST decodes by exactly the same rules the JSON path uses.
The committed example
seed.Example is the embedded org → project → document fixture stamped to
account acme (seed.ExampleAccount). It is what aperture check loads when no
--seed file is supplied, and it backs the end-to-end test.
Exporting
Export(ctx, store) reads the complete model back out into a Document, and
Marshal(doc, format) renders it to on-disk bytes:
doc, _ := seed.Export(ctx, store)
out, _ := seed.Marshal(doc, seed.FormatJSON)
Export captures every source-of-truth entity: accounts, memberships, object types, permissions, principals, roles, groups, grants, templates, and rule ASTs. Two properties make a round-trip trustworthy:
- Byte-stable. Every slice is emitted in a stable order (sorted by id, name, or natural key) and each rule AST is re-serialized to the rules package's canonical form, so a re-export of an unchanged model is byte-identical and human-diffable.
- Wildcard edges are preserved. Memberships and grants stamped to the wildcard
account
*(the cross-account super-admin reach) are not among the real accounts, soExportqueries*explicitly — omitting it would silently drop a super-admin's reach on export/import.
Inline object metadata
For a small or fixed object set, objects: declares metadata in the seed file
itself, with no separate CSV beside it. YAML nests natively, so this is the most
direct way to author the arrays and nested objects the
value model admits:
objects:
- id: account:acme/brand:1
metadata:
tier: gold
seats: 5
tags: [premium, launch]
owner:
dept: eng
lead: alice
- id: account:acme/brand:2 # metadata: may be omitted entirely
- id: account:acme/app:be
metadata: { tier: gold }
The object-type is derived from the identity's terminal segment —
account:acme/brand:1 is a brand — never declared separately, so one fact in
one place cannot disagree with itself. Entries of different types may be
interleaved freely; BuildRegistry groups them and registers one in-memory
provider.Static per type, with
a TTL of 0 (the data cannot go stale, because nothing can change it). Within a
type, declaration order is preserved — it is the order List and Query
return.
The provider it builds is a full one: Fetch (with APERTURE_NOT_FOUND for an
undeclared id), List, and Query honouring Pattern, Limit, and Fields on
the same contract every other provider
implements — a collection field matches by membership, everything else by typed
equality.
Values are validated against the shared value model when the document is
built, before any Check can see them, and numbers are normalised exactly as
every other loader normalises them: an exact integer that fits int64 becomes an
int64, anything else a float64. That is what keeps object.seats == 5 from
answering differently depending on whether the object came from a seed file, a
JSON seed file, or a CSV :int column.
A missing or duplicate id, metadata that is not a mapping, or a value the
value model rejects is APERTURE_CONFIG_INVALID naming the object id and the
field (with the inner APERTURE_METADATA_INVALID kept in the chain); a malformed
id is APERTURE_IDENTITY_INVALID. Ids are deduplicated across the whole
section, not per type — the same id declared twice is the same object declared
twice, however it is spelled.
Declared field types
A CSV header can say hired_at:date; a YAML mapping has nowhere to put that, so
hired_at: 2026-02-30 in an objects: entry loads happily as an ordinary string
and only shows up months later as a rule that silently never matches. The
optional field_types: section is the missing declaration:
field_types:
- object_type: brand
fields:
hired_at: date
last_seen: datetime
objects:
- id: account:acme/brand:1
metadata:
hired_at: "2026-03-04"
last_seen: "2026-03-04T12:30:00Z"
Each entry names an object_type — matched against the identity's terminal
segment, exactly as providers: is — and maps field names to a type. The
vocabulary is exactly two words, date and datetime, lower-case and exact:
the CSV loader's column-suffix spelling with the colon removed, so the same two
words mean the same two things in both loaders. timestamp, Date, and time
are APERTURE_CONFIG_INVALID, never a silently ignored declaration. Each object
type may be declared at most once.
Quote date values in YAML. YAML resolves an unquoted calendar day as a
!!timestamp, and the loader's YAML path normalises through JSON, sohired_at: 2026-03-04arrives already widened to2026-03-04T00:00:00Zand a field declareddaterejects it. The widening happens inside the YAML decoder, before the seed package sees the document, so it cannot be undone — the rejection names the fix when the instant is exactly midnight. A JSON seed has no such trap, and the two formats agree on every quoted value.
Declared values are validated and canonicalised at BuildRegistry time,
through the same provider.ParseDateValue
the CSV loader uses — this document never re-implements what a date is. The
canonical text is what is stored, so 2026-03-04T01:02:03.456Z and
2026-03-04T01:02:03 both become 2026-03-04T01:02:03Z and two objects naming
one instant hold one string, which is what makes a Filter.Fields equality
predicate over the field mean anything. The declared type also fixes the
granularity: a date field rejects a timestamp and a datetime field rejects
a bare day, rather than quietly widening the day to midnight.
Four properties are worth stating because each is the first question a reader asks:
- A declared field is not a required field. The section declares a type,
not a requirement: an object that omits the field is perfectly valid. An
explicitly empty value omits the field rather than storing
""— the same rule an empty CSV cell follows, because an absent date differs meaningfully from any date and a zero time would silently satisfy everybeforerule ever written against the field. - Declaring a type for an object type with no
objects:entries is legal. The entries may be arriving, or the type may be served by aproviders:entry. It registers nothing on its own; the declaration itself is still validated, so a typo fails the build rather than waiting for an entry to expose it. - It applies to
objects:only, never to provider-loaded rows. Aproviders:entry carries its own typing (the CSV:date/:datetimesuffix). One type declaration living in two places could disagree with itself, which is exactly what this document's derive-the-type-from-the-identity rule avoids elsewhere. - It is not a general metadata schema. No
required:, nodefault:, noenum:, nopattern:, noint/float/bool, no nested field paths. The value model already governs shape, depth, and size; the one thing it cannot govern is which strings a host means as dates, because nothing in a string says so.
A rejection is APERTURE_CONFIG_INVALID naming the object id and the field
and carrying the provider.DateReason for a parse failure (a granularity
mismatch is not a parse failure and carries no reason, so DateReasonOf
correctly reports none). It never carries the value — a date is frequently
personal data, and an error is a thing that gets logged.
Like providers: and objects:, this is runtime wiring: Apply writes no
row for it and an export never reproduces it.
When both sections claim a type
providers: and objects: can each claim the same object type. Precedence is
type-level and total:
If a
providers:entry exists for an object type, the declared provider —csvorsqlalike — wins and every inlineobjects:entry for that type is discarded.
There is no object-level merge, no field-level merge, and no fallback. An
inline id the provider's source happens to lack is simply not resolvable —
Fetch returns APERTURE_NOT_FOUND and enumeration never lists it, exactly as
if the entry had never been written. A field only the inline entry declared does
not appear on an object the source does carry.
That is deliberate. Field-level merging is the most useful-sounding behaviour and the most impossible to debug: a rule reading a field the CSV or table silently did not override is a support ticket nobody can reproduce. Predictability wins.
This is the default. A collision builds, it does not fail. Pointing a type at
a CSV or a table while its inline entries are still in the file is an ordinary
migration step, not an authoring fault — a seed that booted yesterday must not
refuse to boot today because someone added a providers: row.
The discard is not silent, though. BuildRegistry needs no logger to say so,
because the document can be asked directly:
reg, err := doc.BuildRegistry(dir)
if err != nil {
return err
}
if types := doc.ProviderCollisions(); len(types) > 0 {
slog.Warn("seed: inline objects discarded, providers: entry wins",
"object_types", types)
}
ProviderCollisions returns exactly the object types whose inline entries the
build discarded — sorted, deduplicated, and object types only, never object
ids (an id can embed an account, and this value is destined for a log line). It
reads the document alone — no file IO, no registry — so it answers the same
before and after a build, and a host surfaces it however it already surfaces
things. Nothing in seed picks a logger for you.
A host that would rather read the overlap as an authoring mistake — a checked-in seed nobody is mid-migration on — opts into a refusal:
reg, err := doc.BuildRegistry(dir, seed.StrictProviderCollision())
That returns APERTURE_CONFIG_INVALID naming every colliding object type
(sorted, in both the message and the error context — never the object ids). It is
Go wiring rather than a seed-file key on purpose: the file stays a plain
declaration of what exists, and the choice to make an ambiguous one fatal sits in
code, where a reviewer sees it.
Validation is independent of precedence. Every inline entry is checked —
id, identity, duplicates, the value model, and its
declared field types — before any type is discarded,
so a malformed declaration fails the load whether or not its type ultimately
loses to a providers: entry. Otherwise a document would silently stop being
validated the day someone added a CSV for one of its types. The canonicalised
values are then discarded along with the rest of those entries: a field_types:
declaration never reaches the rows a declared provider serves — a SQL provider's
column typing lives in its statement's casts, not here.
Two providers: entries for one type remain a duplicate registration,
APERTURE_PROVIDER_INVALID — that is a straight contradiction with no winner to
pick, not a precedence question.
Database-backed providers
A providers: entry declares its kind. csv names a file resolved relative to
the seed file; sql names two statements run against a connection
declared in the document's top-level connections: block. Together they make a
database-backed deployment a no-Go-code experience:
connections:
main:
dsn_env: APP_DATABASE_URL # required — the ONLY way to supply a DSN
max_open_conns: 8 # optional
max_idle_conns: 4 # optional
conn_max_lifetime: 1h # optional
query_timeout: 3s # optional
providers:
- object_type: brand
kind: sql
connection: main
get_one: SELECT tier, seats, to_jsonb(tags) AS tags FROM brands WHERE id = $1
get_all: SELECT 'brand:' || b.id AS id, b.tier, b.seats FROM brands b
ttl: "30s"
| Provider key | Meaning |
|---|---|
connection | the connections: entry to read through. Required for kind: sql; a name with no matching entry is a hard error at build. |
get_one | the "get one" statement, taking exactly one placeholder, to which the identity's terminal segment value is bound |
get_all | the "get all" statement, taking no parameters and selecting each row's full identity as the id column. Required alongside get_one — a provider that could be fetched from but not enumerated would answer List with an error, and an errored enumeration reads as "no access". |
id_column | the get_all result column holding the identity. Default id. |
references | declared object references: field name → target object-type. See Declaring a reference. Works on any kind. |
ttl / max_size | the per-type cache options, as for any provider entry |
The statements are the developer's own, and the SELECT list is where a
column's type is decided — there is no per-column type declaration here the way
a CSV header carries :int. Cast arrays with to_jsonb(...), day-granular dates
with ::text, and a numeric with ::float8 or ::text; compose the identity
in the id column. Getting a cast wrong is not always an error — SELECT tags
yields the raw array literal as a string, and every membership predicate over
it then silently matches nothing. See
the SQL provider.
Project the same columns in get_one and get_all (the id column aside) unless
you mean not to. The two SELECT lists are what decide whether an enumeration may warm
the registry's per-type metadata cache, and an unequal pair silently gives that up —
correctly, because a bag from a narrower listing would make a rule read
object.<dropped_field> as absent, which denies an inclusive grant and stops an
exclusive one excluding. The pairing is compared from the columns the statements
really return, so there is no key here to declare it with. See
The listing and the fetch must be the same bag.
There is no dsn: key
A seed file is a committed artifact, and a DSN carries a password. Naming an
environment variable is therefore not the recommended spelling — it is the only
one. A literal dsn: is refused by Parse with
APERTURE_SQL_PROVIDER_DSN_LITERAL, before the document is usable for
anything: not by Apply, not by an export round-trip, not by a tool that only
wanted to read the object types. The refusal names the offending connection and
never the value, and any DSN is redacted out of driver messages these errors
carry.
One pool per named connection, and its defaults
The pool is opened once per declared connection and shared by every provider
entry naming it: three kind: sql entries over one database are three providers
and one pool. Every declared connection is opened, not only the referenced
ones — a declared-but-unused name is far more likely a typo'd connection: than
a deliberate spare. The query timeout belongs to the connection, since it is a
property of the database being read.
| Key | Default | Notes |
|---|---|---|
query_timeout | 5s | bounds one statement. Must be positive — there is no "no timeout" setting, because an unbounded statement under Check is an unbounded decision. |
max_open_conns | 10 | deliberately not database/sql's unlimited default; negative restores it. An unbounded pool lets a burst of Checks exhaust the host's server. |
max_idle_conns | 5 | zero or negative retains none. Provider traffic is bursty, so keeping half the pool warm avoids re-paying TLS and auth on every wave. |
conn_max_lifetime | 30m | "0" means reuse forever. A finite lifetime is what survives a connection proxy or a failover pair. |
Durations are Go durations ("30m", "500ms"). An unparseable one, a negative
conn_max_lifetime, or a non-positive query_timeout is
APERTURE_SQL_PROVIDER_CONNECTION at build.
Pools have a lifetime, so the build signature changes
A document that declares connections: cannot be built through the
one-return BuildRegistry — a pool has a lifetime that signature cannot hand
back, so rather than open pools nothing can close, it refuses the document with
APERTURE_SQL_PROVIDER_CONNECTION naming the call that works:
reg, conns, err := doc.BuildRegistryWithConnections(dir)
if err != nil {
return err
}
defer conns.Close()
This is the one place a valid seed file fails the simple call. Every other
document — csv providers, inline objects:, no connections: block — still
builds through BuildRegistry unchanged.
*seed.Connections is the registry's other half: a *provider.Registry holds no
resources, the pools beneath a SQL-backed entry do. It is always non-nil on
success and empty (Close a no-op) for a document declaring no connections, so a
caller defers unconditionally without asking which kinds the seed used. Close
is idempotent and joins the failures of every pool it closes. Aperture's own CLI
owns and closes them for the duration of a command.
Nothing is dialled at build
sql.Open is lazy and Aperture does not ping. A wrong host, port, password,
or a database that is simply down surfaces on the first decision that touches a
SQL-backed object-type, as APERTURE_SQL_PROVIDER_QUERY — not at startup.
Making registry construction wait on a round-trip would make every
aperture check a network operation, including the ones that touch no SQL-backed
type, and would stop a process from booting while the host's database is still
starting.
Everything Aperture can decide without dialling is eager, because a connection
that only fails under a decision fails as a denial: the dsn_env variable
must be set and non-empty, the durations must parse, query_timeout must be
positive, every connection: must name a declared connection, and both
statements must be present. A failure takes the whole build with it and closes
every pool opened so far, so a failed build strands nothing.
Declaring a reference
Any providers: entry — csv or sql — may declare object references: a
mapping from a metadata field name this provider serves to the target
object-type its values identify. It is an application-level foreign key with no
database constraint behind it, so the document is where it is stated:
providers:
- object_type: dataset
kind: sql
connection: main
get_one: SELECT d.tier, to_jsonb(d.brand_ids) AS current_brands FROM datasets d WHERE d.id = $1
get_all: SELECT 'account:acme/dataset:' || d.id AS id,
to_jsonb(d.brand_ids) AS current_brands
FROM datasets d
references:
current_brands: brand
- object_type: brand
kind: sql
connection: main
get_one: SELECT b.region FROM brands b WHERE b.id = $1
get_all: SELECT 'account:acme/brand:' || b.id AS id, b.region FROM brands b
That declaration is what lets an enumeration be restricted to what a holder
object names — aperture enumerate … --via account:acme/dataset:x.current_brands,
"which brands belong to dataset x?".
Four rules govern the block, and each closes a door deliberately:
- It is declared on the HOLDING side only — the type whose provider actually
returns the field. There is no inbound spelling on
brand:brandhas no column listing its datasets, so an inbound declaration would describe a derived view with nothing to attach to, and a second referencing field (archived_brands: brand) would make the unnamed reverse edge ambiguous. - It is a closed set of one descriptor kind. A field maps to a target
object-type and to nothing else. Do not add a
type:key here: the loader is the single typing mechanism (a CSV column suffix, a cast in the developer's SQL), and a second place to declare a type is a second place for the two to disagree. - The field's values are full canonical identities —
"account:acme/brand:1", composed by the developer where the data is loaded, scalar for one and a list for many. Spell them exactly as the object lister yields them; a bare"brand:1"in an account-scoped deployment passes the declaration check and then resolves to nothing. - The blocks are applied last, in a second pass, once every type is
registered — so a reference may name a target declared further down the file,
or one served by the
objects:section. Order in the file never matters.
A target no provider serves is APERTURE_PROVIDER_REFERENCE_INVALID at build,
naming the field and the target. A field name no object happens to carry is not
an error at all: metadata fields are discovered at fetch, not declared, so it
simply resolves to nothing.
Like every other providers: key, references: is runtime wiring, not model
state: Apply writes nothing for it and an export reproduces none of it. It is
shared with the rest of the section — aperture wiring push flattens the map into
apt_wiring_provider_references — so a second instance reads the same declarations.
See Declared references for what a declaration
buys and the security semantics of enumerating through one.
Inline subject attributes
objects: says what Aperture knows about the thing being acted on. attributes:
says what it knows about the party asking — a principal's department, an
account's plan — so a small deployment can make principal.department mean
something with no directory, no CSV, and no Go:
attributes:
- subject: user
id: alice
metadata:
department: eng
clearance: 3
teams: [platform, infra]
- subject: machine
id: ci-runner
metadata: { department: eng }
- subject: account
id: acme
metadata: { plan: enterprise }
subject: names one of the three attribute slots — user or machine for a
principal, account for the tenant a decision is made in. The set is closed: it
is the parties a decision has. It is spelled subject: rather than kind:
because account is a slot but not a principal kind, and a key called kind:
would read as model.PrincipalKind. An unknown value is
APERTURE_ATTRIBUTE_SLOT_UNKNOWN naming the entry and the three legal subjects.
id: is the bare attribute key — a principal id, or an account id — and
Aperture never parses it. There is no type to derive from it the way an objects:
id derives its object-type from its terminal segment: an attribute key is an
opaque handle into the host's directory with no segment structure, which is
exactly why the slot has to be declared. Keys are deduplicated per slot, not
across the section: a tenant called acme and a service principal called acme
are two unrelated subjects, while the same key twice under one subject would let
the last writer silently win.
metadata: is the ordinary value model,
validated at build and with numbers normalised exactly as objects: normalises
them — there is one value model in Aperture and an attribute bag is a value in it.
A rejected value keeps the model's own APERTURE_METADATA_INVALID (the code whose
fixups name the legal shapes and the two caps), with the offending entry added to
the message; a missing subject:/id:, a duplicate pair, or a metadata: that
is not a mapping is APERTURE_CONFIG_INVALID. The account wildcard "*" is never
a legal key — it would ask for the attributes of every account at once — and is
refused with APERTURE_ATTRIBUTE_PROVIDER_INVALID.
Document.BuildAttributeRegistry(baseDir) turns the block into a live
*provider.AttributeRegistry, registering one in-memory provider per declared
slot — in that slot's local layer, with a TTL of 0. It always returns a usable
registry, so a host wires it unconditionally:
attrs, err := doc.BuildAttributeRegistry(filepath.Dir(seedPath))
if err != nil {
return err
}
eng := rules.NewEngine(ruleSource, objectFetcher, rules.WithPrincipalResolver(attrs))
The kind picks the slot, and a missing source is not a failed decision: a
subject with no entry — or a slot with no provider at all — evaluates against the
floor bag ({id, kind}), so a rule reading an attribute nobody declared is
deny-safe rather than a non-decision. See
Wiring a *provider.AttributeRegistry.
To back a slot with the host's real directory instead of an inline list, declare
it under attribute_providers:. The two sections are
not exclusive: when both claim one slot, the external entry becomes that slot's
shared layer and the inline bags layer under it — see
Precedence: two layers, and the shared layer wins.
Why it is not a metadata: field on principals:
principals: and accounts: are model state — rows Apply writes and
Export reads back. An attribute bag is not: it belongs to the host's directory,
and Aperture persists none of it (there is no column for it, by design). Hanging
the bag off a model entry would put wiring inside state, and the export would then
be one of two bad things — lossy, because it silently dropped the bags it
could not read out of storage, or untruthful, because it invented them. Its
own key keeps the line exactly where the other wiring sections already keep it.
External attribute sources
attributes: lists bags inline. attribute_providers: points a slot at the
host's real directory instead — a CSV export, or the users and accounts
tables the deployment already has — one entry per slot:
connections:
main:
dsn_env: APP_DATABASE_URL
attribute_providers:
- subject: user
kind: sql
connection: main
get_one: SELECT department, clearance FROM users WHERE id = $1
get_all: SELECT u.id AS id, u.department, u.clearance FROM users u
ttl: 60s
- subject: machine
kind: csv
path: machines.csv
subject: names the slot exactly as it does on attributes: — user,
machine, or account — and each slot may be declared at most once in this
section. Declaring it in attributes: as well is not a conflict: that block
becomes the slot's local layer, under this entry
(Precedence).
kind: is the implementation, csv or sql; the two words are why the slot is
spelled subject: here rather than kind:. Everything Aperture can check
without reading a file or dialling a database is checked at build: the
subject must name a slot, the kind must be one this build knows, a csv entry
must carry a path:, a sql entry must carry a connection: naming a
declared connections: entry plus a get_one:, and a ttl: must parse. A
source that only failed under a decision would fail as a denial.
ttl: and max_size: are per slot, not per document: the three slots have
genuinely different change rates and cardinalities, and one number covering all
of them would tune for whichever entry was declared last. ttl: "0" never
expires. A slot's TTL is the window a revoked clearance keeps authorizing
for — see aperture attributes, which reads it back and
can close it. It is also per layer: a slot with an external source and an inline
block caches each independently, so this ttl: is this source's own window and the
inline bags' 0 is not averaged into it.
dsn: is refused by name wherever it appears, here as on a providers:
entry: credentials belong to a connections: entry's dsn_env:.
declared_keys: — the keys a slot guarantees
An entry may declare the attribute keys it guarantees. The key is optional, and omitting it is legal:
attribute_providers:
- subject: user
kind: sql
connection: main
get_one: SELECT department, clearance FROM users WHERE id = $1
declared_keys: [department, clearance]
Declaring does two jobs with one list, and each fixes what the other cannot.
Key enforcement. A rule may then read only the keys the set names on that slot. Declaring nothing opts out, and a slot with no declared set behaves exactly as every slot did before the key existed.
Reservation. The declaring layer becomes the only layer that answers those
keys. A slot holds a shared layer and a local one, and this section is the shared one,
so the inline attributes: block contributes nothing to
a declared key — not when the two disagree, not when this entry's own bag omits it
for one row (a SQL NULL is an absent field), and not when this entry has no
record for the subject at all. Keys outside the set are untouched.
Together they make a local attribute layer safe in both directions. Enforcement covers
the keys a local layer adds: they are unreachable from any rule the deployment can
validate — inert, rather than a second answer to a deployment-wide grant.
Reservation covers the keys inside the set, where the merge used to fall through to
the local file on an absent value or an absent record. The second half is a
revocation control: deleting a subject from the shared directory removes every
declared key for them, on every instance, whatever a local file still says — which a
ttl: cannot do, since the inline layer is registered with ttl: 0 and has nothing
stale to drop.
The declared set therefore lives on the shared entry and nowhere else: a local layer able to narrow or widen it would be one machine changing which keys a deployment-wide rule may name, and which keys the deployment's own directory is allowed to answer.
Reservation is opt-in and is not a discard — a slot that declares nothing reserves nothing, and a key outside a declared set still answers from the local layer. It also cannot reach the engine's floor. The registry, the two layers, and the revocation window is the full account.
The set is a plain list of names, with no per-key type information — the simplest
form that round-trips, and the right one, because the metadata value
model already governs shape and field_types: already governs the
declared date types. A second typing mechanism would be a second place for two
declarations about one key to disagree.
There are three states, not two:
| Written | State | Effect |
|---|---|---|
declared_keys: absent (or null) | not declared | the slot is opted out of key enforcement |
declared_keys: [] | declared empty | the slot is opted in and permits no key |
declared_keys: [a, b] | declared | permits a and b |
The middle row is the one a plain list of strings would lose, since nil is what both
an absent and an empty list decode to. So the distinction is carried explicitly at
every layer it crosses — the YAML field is a pointer, the stored row carries a
Declared bit of its own, and aperture wiring show prints all three as words
((not declared), (declared empty), or the names). Collapsing declared-empty into
not-declared would silently un-enforce a slot.
Names are trimmed; an empty or repeated name is refused with
APERTURE_CONFIG_INVALID naming the slot and the key. The declaration order is
preserved, so aperture wiring pull reproduces the author's
list rather than a sorted paraphrase, and push → pull → push is a fixed point for a
slot that declares a set.
What a declaring slot refuses
This is the first of the two jobs — the rule gate. Reservation is the second, and it is applied by the registry when it merges the two layers, so the two have different failure modes and are not the same mechanism.
Enforcement is definition-time, in rule validation, and never at decision time: a
rule reading a key the slot does not declare is refused when it is saved or checked
(APERTURE_RULE_UNDECLARED_ATTRIBUTE, naming the key and the slot, shown on the rule
editor's canvas), while a rule already stored keeps deciding exactly as it did. That
covers importing a document as well as saving one rule: aperture import and the
Import rpc refuse the whole file, before anything is applied, if any rule in it
reads a key the wiring does not declare. Passing that file as --seed is refused the
same way, and the instance does not start — the check runs before the document is
applied, because a rule that reaches storage is in the shared database and every
other instance then has it. A
refusal in production, on some instances and not others, would be the very divergence
the declared set removes.
Two things a declared set never covers:
- The floor.
principal.id,principal.kindandaccount.idare stamped by the engine over every resolver's answer, so they are always readable and are never part of a declared set. Declaring them is redundant, not required. - A whole-bag read. A bare
principaloraccountwith no path reads whatever the bag carries, so it is refused outright by a declaring root rather than treated as naming nothing.
The account root is backed by one slot and is enforced when that slot declares. The
principal root is backed by two — user and machine — and principal.* resolves
to one or the other by the asking principal's kind, which validation cannot know: so
the permitted set is the union of the two, and the root is enforced only when
both slots declare. skills/attribute-providers.md has the argument for each half.
The bare-id contract
An attribute key is a bare principal id or account id — an opaque handle into the host's directory that Aperture never parses. An object id is a segmented identity. The two statements therefore differ, in both directions:
-- providers: an OBJECT provider selects the FULL IDENTITY
get_all: SELECT 'user:' || u.id AS id, u.department FROM users u -- WRONG here
-- attribute_providers: an ATTRIBUTE provider selects a BARE ID
get_all: SELECT u.id AS id, u.department FROM users u -- CORRECT
get_one: differs the same way. An object provider binds the identity's
terminal segment value (brand:42 and account:acme/brand:42 both bind
42); an attribute provider binds the bare subject id verbatim, because
there is nothing to strip. The same applies to a csv entry's id column:
alice, not user:alice.
Nothing can catch a mistake here. An identity-shaped key is a perfectly legal
opaque string: it enumerates happily, caches happily, and then matches no
principal id any fetch ever presents, because the decision path fetches by the
bare id. The slot simply never answers, and nothing anywhere complains. There is
no check any package could add — the key is opaque, so there is nothing to test
it against — which is exactly why the asymmetry is written down here, on
seed.AttributeProvider.GetAll, and in both loaders' package docs. It is also
why attribute_providers: is a separate top-level key rather than a variant of
providers:: sharing one struct would make copying a statement between them a
silent fault.
get_all: is optional, where an object provider's is required
A providers: entry must declare both statements, because an object provider
that can be fetched from but not enumerated answers List with an error, and an
errored enumeration reads as "no access" one layer up — a denial caused by a
wiring gap.
That reason does not apply to an attribute slot: attribute enumeration never
participates in scope resolution (see Attribute
providers). Omitting get_all: yields a
fetch-only slot — every decision path works unchanged, and only the
administrative listing refuses, with a coded error naming the statement to
declare. That is a feature: a host can let Aperture read the attributes of the
principal currently being decided about without exposing its whole user
table to an admin enumeration.
Building it, and the shared pool set
reg, conns, err := doc.BuildRegistryWithConnections(dir) // object providers
if err != nil {
return err
}
defer conns.Close()
attrs, err := doc.BuildAttributeRegistryWithConnections(dir, conns)
BuildAttributeRegistryWithConnections takes a *Connections rather than
opening one, and that is the point: connections: is the document's single
pool set, and the object registry and the attribute registry read through the
same pools. An entry point that opened its own would double every deployment's
connections and hand nothing back to close them. The plain
BuildAttributeRegistry(baseDir) passes no pools — correct for a document whose
attribute sources are all csv or inline — so a kind: sql entry fails there
with APERTURE_SQL_PROVIDER_CONNECTION naming the form to call, rather than
lazily on the first decision that needed the database.
Slots are filled in slot order (user, machine, account), not file order, so
a document with two bad slots always fails on the same one.
Precedence: two layers, and the shared layer wins
When both sections declare the same slot, nothing is discarded. The
attribute_providers: entry becomes that slot's shared layer, the inline
attributes: block becomes its local layer, a fetch reads their merge, and
the shared layer wins every key both serve. So an inline id the external source
lacks is resolvable — that is what the local layer is for — while an inline value
for a key the external source does serve is never read, on any instance.
A key the external source declares goes further: it is reserved to the shared
layer, so the inline block contributes nothing to it even where the external source
omits it for one row or has no record for the subject at all. That is what makes
deleting a subject from the shared directory a revocation on an instance whose own
file still lists them — see
declared_keys:. It is opt-in, and a
slot that declares nothing merges exactly as it did.
This is deliberately not the providers: / objects:
rule at slot granularity. That one really is a
discard. These two sections are not two candidates for one slot: a shared directory
the deployment administers and a block in one instance's file are two layers of
it, and refusing the second meant an instance could not add a field the directory
does not carry without abandoning the directory.
Which section is which layer is not a choice. attribute_providers: names a source
every instance of the deployment reads — a shared wiring
row projected back into that section, or a directory — while
attributes: is data written into one instance's file. If the file could override a
key the directory serves, a file on one machine would change what
principal.clearance >= 3 compares against on that machine only: the same rule,
the same grant, a different verdict, with nothing in a verdict, a trace or a note to
say which layer answered. The precedence is fixed, unconfigurable, and independent of
the order things were registered in. It mirrors the engine's floor
bag one tier down, so the three tiers
compose in one direction: floor over shared over local.
What remains refused is field-level merging with a configurable or order-dependent
winner — a rule reading a department one machine's file silently overrode is a support
ticket nobody can reproduce. A fixed winner that is the deployment-wide source makes a
contested key read the same on every instance, and
declared_keys: is the other half, in two
ways: the keys only a local layer serves are unreachable from any rule the deployment
can validate, and the keys the shared entry declares are reserved to it, so those
read the same on every instance even where the shared bag is silent.
The layering is not silent. Document.AttributeCollisions() reports the affected
slots and the caller surfaces them (aperture prints a warning) — for a different
reason than the object case. There the warning says data was discarded; here it says
which layer answers a contested key, which is what an operator debugging an
unexpected attribute value needs told and which no verdict, trace or note says. Only
slot names are reported, never keys, so the warning cannot leak a directory's
contents. Document.AttributeSlotSources() reports where each slot's bags come from —
"csv", "sql", or "inline" — naming the winner for a slot both sections fill,
so a surface that displays the wiring reads the rule instead of re-deriving it and
eventually disagreeing with it.
What is not in the file
Three things are deliberately excluded from the model state file:
- Live host domain-object metadata — that is the provider
cache: derived, disposable, never source of truth. Because
Exportreads storage back, and a provider produces no model rows, it is never reproduced. - Live subject attributes — a principal's or an account's bag is the host
directory's, for the same reason and with the same consequence:
Applywrites no row forattributes:orattribute_providers:and an export reproduces neither. The shared wiring tables hold anattribute_providers:entry's pointer to a directory; no table anywhere holds a bag. - Runtime wiring — the
connections:,providers:,objects:,field_types:,attributes:andattribute_providers:sections are runtime wiring, not model state.Applynever writes any of them to storage; insteadDocument.BuildRegistry(baseDir)— orBuildRegistryWithConnectionswhen the document declaresconnections:— turns the first four into a live*provider.Registry, andDocument.BuildAttributeRegistry(baseDir)— orBuildAttributeRegistryWithConnectionswhen an attribute source iskind: sql— turns the last two into a live*provider.AttributeRegistry. An export reproduces none of them. A declared provider names anobject_type, akind(csvorsql), optional cachettl/max_size, and then either apath(forcsv, resolved relative to the seed file) or aconnectionplusget_one/get_allstatements (forsql— see Database-backed providers), plus an optionalreferences:block (see Declaring a reference). A malformed entry isAPERTURE_CONFIG_INVALID/APERTURE_PROVIDER_INVALID, and a broken connection declaration isAPERTURE_SQL_PROVIDER_CONNECTIONorAPERTURE_SQL_PROVIDER_DSN_LITERAL. Whenproviders:andobjects:claim one type,providers:wins the type outright — see When both sections claim a type.
The file is not the only home for wiring
"Apply writes no wiring" and "an export reproduces no wiring" are both still true,
and neither means the seed file is the only place a deployment's wiring can live.
Four of the six wiring sections are shared, and aperture wiring push writes
them to the five apt_wiring_* tables
every instance of a deployment already holds:
| Section | Home | Read back by |
|---|---|---|
connections: | shared — the manifest of names, and nothing else | aperture wiring pull |
providers: (with its references:) | shared | aperture wiring pull |
field_types: | shared | aperture wiring pull |
attribute_providers: | shared | aperture wiring pull |
objects: | local to the instance whose file lists it | nothing |
attributes: | local to the instance whose file lists it | nothing |
The line between the two halves is a pointer to data versus the data itself. A
providers: or attribute_providers: entry says where to read metadata or an
attribute bag from, which is a fact about the deployment and safe to copy to a second
instance. An objects: or attributes: entry carries the metadata or the bag, and
Aperture's own database is never the source of truth for a host's domain data — the
same Non-Goal that keeps the provider cache out of an export.
Three read-backs therefore exist and answer three different questions. Export
emits the model and no wiring, and is reachable over Twirp with an admin-tier
token. aperture wiring pull emits the four shared wiring sections and no
model, is CLI-only, and is gated by the store credential alone. Nothing emits the
two local sections, because nothing put them anywhere but the file.
A pushed row carries no secret and no path — not a DSN, not a credential, not
even the dsn_env: variable name, and no filesystem path — because a row is copied
to a second instance that resolves its own credentials and has its own disk. That is
why kind: csv is refused at the push and stays perfectly legal in a local file.
The whole contract, including what an instance does when both the database and its
file declare the same entry, is on aperture wiring and in
Two instances, one store.
Related
- The RBAC model — the entities the document mirrors.
- Rules engine — the canonical AST a rule's
astfield carries. - Providers — the registry
providers:wiring builds, and the cache that is never exported. - Storage — the
StoragebackendApplywrites through andExportreads back. - Portability CLI — the command surface over import/export.
aperture wiring— the command surface over the four shared wiring sections.- Two instances, one store — the topology the shared sections exist for.
Error taxonomy
Every failure surfaced by the Aperture library is an APERTURE_* coded error.
Codes exist so the CLI, Twirp/HTTP, and MCP surfaces can translate a failure to a
transport-appropriate status without string-matching human-readable messages —
the code is the stable contract, the message is not.
This page is the concept. The exhaustive, generated list of every code with its message and fixups is the Error Codes reference, produced from the
Registryinerrors/codes.go.
The coded-error type
CodedError is the canonical error type:
type CodedError struct {
Code Code // the failure class, e.g. APERTURE_NOT_FOUND
Msg string // human-readable summary
Context map[string]any // structured detail
Inner error // the wrapped cause
}
It implements error and Unwrap, so errors.Is / errors.As inspect it
normally.
Constructing and recovering codes
Construct a coded error with one of the wrappers; recover the code with CodeOf:
| Constructor | Use |
|---|---|
New(code, msg) | a fresh error; empty msg falls back to the code's canonical Registry message |
Newf(code, format, …) | New with a formatted message |
WithContext(code, msg, ctx) | a fresh error carrying a structured Context map |
Wrap(code, msg, inner) | attach a code + summary to an existing error |
Wrapf(code, inner, format, …) | Wrap with a formatted message |
CodeOf(err) Code | the APERTURE_* code for an error, or "" when none is attached |
if _, err := store.GetGrant(ctx, id); err != nil {
if errors.CodeOf(err) == errors.APERTURE_NOT_FOUND {
// ... handle the absent grant
}
}
Wrapping rules
Two rules govern how codes propagate:
- Never re-stamp. An error that already carries an
APERTURE_*code passes through verbatim.CodeOfrecovers the existing code; the wrappers do not overwrite it. This lets a lower layer set the precise code (e.g. a provider returningAPERTURE_NOT_FOUNDfor an absent object) and have it survive unchanged up through the callers. - Never leak cross-account data. A code and its message must not carry another
account's entity ids, names, or contents. Cross-account isolation is a hard
invariant of the engine; an error message is not an exception to it. Put
narrowing detail in
Context(structured, controllable) rather than interpolating tenant data into free-text messages.
Across package boundaries, never return a bare errors.New / fmt.Errorf —
wrap it in an APERTURE_* coded error so every surface can translate it.
The Registry and its gates
Every code has one entry in Registry (the Orbit pattern), a
map[Code]Metadata:
- a canonical
Message(the fallback summary and the reference-table text), and - either at least one
Fixup(an operator-actionable hint) orFixupNotApplicable = truewhen no fixup is meaningful.
Codes are SCREAMING_SNAKE, APERTURE_-prefixed, and listed in the AllCodes
slice. This structure is enforced by non-skippable CI gates:
| Gate | Enforces |
|---|---|
TestCodesHaveFixups | every code has a Registry entry with a Message and a Fixup (or FixupNotApplicable) |
TestRegistryHasNoOrphans | Registry contains nothing absent from AllCodes |
TestCodesAreScreamingSnakeNamespaced | every code is SCREAMING_SNAKE and APERTURE_-prefixed |
Adding a code therefore means: append it to AllCodes, and add a Registry entry
with a message and fixups — or the build fails.
Related
- Error Codes reference — the generated table of every code, message, and fixup.
- The decision engine — the surface whose failures these codes classify.
- Authentication, The authz gate, Storage — packages that raise the auth, authorization, and storage codes.
Storage
model.Storage is Aperture's persistence boundary — the single seam every
backend implements. Three backends ship, all behind the one interface:
storage/memory— a map-backed, concurrency-safe store for tests, seeding, and any deployment that does not need durability.storage/sqlite— the durable reference backend onmodernc.org/sqlite, a pure-Go driver, soCGO_ENABLED=0holds end to end.storage/postgres— a full peer of the SQLite backend onjackc/pgx/v5/stdlibthroughdatabase/sql, also pure Go. Not a variant: the same tables, the same keys, the same behaviour.
Both SQL backends use a hand-written, embedded schema.sql — 19 tables and 7
indexes, no ORM, no sqlc, no migration tool. Fourteen of those tables are model
state; the other five are shared wiring.
The interface is deliberately free of any backend-specific concept. All three
backends enforce the same validation and typed-action rules and pass the
shared conformance suite (storage/storagetest), which contains no
backend-conditional assertions — so behaviour is identical across them by proof,
not by intention.
The interface contract
type Storage interface {
Setup(ctx context.Context) error // CREATES the schema; idempotent; never migrates
Close() error
// Account, Membership, ObjectType, Permission, Principal, Role, Group, Grant,
// Template, Rule — each with Put/Get/List/Delete as applicable.
// ... plus decision-engine queries, Atomic, and the audit trail.
}
Shape and error conventions, uniform across every entity:
| Operation | Contract |
|---|---|
Put* | upsert keyed on the entity's id (object types on name): create when absent, replace when present; validates its argument |
Get* | returns APERTURE_NOT_FOUND when the id is unknown |
List* | returns every entity of the kind (grants are listed per account) |
Delete* | returns APERTURE_NOT_FOUND when the id is unknown, and APERTURE_STORAGE_CONSTRAINT when children still reference the row |
| any backend failure | surfaces as APERTURE_STORAGE |
PutPermission additionally enforces typed-action validation against the
referenced object type (APERTURE_ACTION_UNDECLARED), and PutGrant validates
that Object parses as an identity pattern and that AccountID is present.
All methods are safe for concurrent use by multiple goroutines. The in-memory
backend guards its maps with a single RWMutex; the SQLite backend caps its
connection pool at one connection, since SQLite is a single-writer engine and one
connection avoids "database is locked" contention; the Postgres backend runs an
ordinary pool.
Timestamps are int64 nanoseconds
Every persisted instant — CreatedAt, UpdatedAt, and an audit event's
timestamp alike — is stored as a signed 64-bit count of nanoseconds since the
Unix epoch, in UTC. INTEGER in SQLite, BIGINT in Postgres, carrying the
identical int64. There is no text timestamp and no per-dialect timestamp type
anywhere in Aperture's storage.
The Go-facing types are unchanged: CreatedAt is still a time.Time, and the
encoding is invisible above the storage layer.
| Property | Value |
|---|---|
| Unset | 0 — not the Unix epoch. It is what the zero time.Time encodes to and what decodes back to it, which is what lets every timestamp column stay NOT NULL DEFAULT 0. |
| Representable window | 1677-09-21T00:12:43.145224192Z .. 2262-04-11T23:47:16.854775807Z |
| Outside that window | refused with APERTURE_INVALID_INPUT before the write — never wrapped, clamped, or stored as an overflow value |
| Round trip | nanosecond-exact, asserted per backend by the conformance suite |
The accepted cost of the 0 sentinel is that an instant of exactly the Unix
epoch is indistinguishable from unset. Aperture stamps from a real clock and
never writes the epoch, so nothing in the model can hit that collision.
Integers rather than text because comparison is the point: audit range
filters and newest-first ordering compare numerically, while RFC3339 text
mis-sorts variable-length fractional seconds. A native timestamp type would also
truncate — PostgreSQL's TIMESTAMPTZ is microsecond resolution and cannot carry
the nanosecond-exact round trip.
storage/storagetime owns the conversion and is the only place in the storage
layer where a time.Time becomes an integer, or an integer a time.Time.
Referential integrity is in the database
Eleven relationship columns carry a real foreign key, identically in both SQL dialects:
| Child column | Parent | ON DELETE |
|---|---|---|
apt_memberships.principal_id | apt_principals(id) | RESTRICT |
apt_permissions.object_type | apt_object_types(name) | RESTRICT |
apt_principal_roles.principal_id | apt_principals(id) | CASCADE |
apt_principal_roles.role_id | apt_roles(id) | RESTRICT |
apt_role_permissions.role_id | apt_roles(id) | CASCADE |
apt_role_permissions.permission_id | apt_permissions(id) | RESTRICT |
apt_group_members.group_id | apt_groups(id) | CASCADE |
apt_group_members.principal_id | apt_principals(id) | RESTRICT |
apt_grants.permission_id | apt_permissions(id) | RESTRICT |
apt_wiring_providers.object_type | apt_object_types(name) | RESTRICT |
apt_wiring_provider_references.object_type | apt_wiring_providers(object_type) | CASCADE |
ON UPDATE RESTRICT throughout, without exception: an id in Aperture is
immutable, so an UPDATE that moved a parent key is a bug and RESTRICT makes it
a loud one.
CASCADE appears only where an entity owns its own child rows — a principal's
role list, a role's permission list, a group's member list, and a provider
entry's references: map. The child row has no meaning without its owner.
Everywhere else the delete is refused, with APERTURE_STORAGE_CONSTRAINT.
Deleting a permission a grant still cites, a role a principal still holds, or a
principal still in a group now fails instead of silently orphaning the child
rows. That refusal is the feature: a grant naming a permission that no longer
exists is not a tidy leftover, it is authority nobody can read or revoke. Over
Twirp the refusal is a 412 Failed Precondition, not a 500, and the code's
registry fixups name the children to remove first.
Three integrity rules cannot be expressed as a foreign key and are enforced in Go instead — in both directions, in every backend, with the same code and the same wording, so a caller cannot tell which mechanism refused:
apt_memberships.account_idandapt_grants.account_idlegitimately holdmodel.AccountWildcard("*"), and"*"is deliberately not an account row —ValidateAccountrefuses it. The parent a foreign key would reference is forbidden by the model, so the key would reject every wildcard grant and every wildcard membership. The rule enforced instead is "anapt_accountsrow or exactly"*"".apt_grants.(subject_kind, subject_id)is polymorphic: the kind selects whether the id must exist inapt_principals,apt_roles, orapt_groups.
Columns that deliberately carry no foreign key
This list is load-bearing. Adding a key to any of these breaks something.
apt_audit_logcarries no foreign keys at all.actor,effective_subject,accountandtargetname entities an audit record must outlive — recording what was done to a principal since deleted is the whole point. A key would either refuse the delete (making the trail the reason you cannot remove a user) or erase the evidence.- JSON value columns —
apt_object_types.apt_actions,apt_templates.apt_grantsandapt_wiring_attribute_providers.declared_keys— are value lists, not relationships.apt_templates.apt_grantsis spelled that way becausegrantsis a reserved word, not because it references theapt_grantstable. apt_grants.subject_id— polymorphic, as above.apt_permissions.scope_strategy— not an id. It is an opaque scope reference resolved against an in-process registry of resolvers, not a table.apt_wiring_providers.apt_connectionandapt_wiring_attribute_providers.apt_connection— the column names a row in the connection manifest, but an entry of a kind that reads no database names no connection, so a key would demand a NULL-encoded absence (an encoding this schema uses nowhere) or a fake''row in every deployment. An unknown name is a coded error when the wiring is built, naming the typo.apt_wiring_field_types.object_type— a field-type declaration may name a type whose objects are listed inline, and such a type needs no object-type row at all, so the parent a key would require may legitimately not exist.apt_wiring_provider_references.target_type— a reference target is resolved against the registry the wiring builds, not against a table.
Each refusal is written into both schema.sql files as a comment, next to the
column it concerns.
SQLite enforces foreign keys only when PRAGMA foreign_keys is ON, which it
defaults OFF and scopes per connection. sqlite.Open therefore forces
_pragma=foreign_keys(1) into every DSN it opens, whatever the caller passed,
and Setup verifies it and refuses a non-enforcing connection. PostgreSQL
enforces unconditionally and has no equivalent switch.
The five shared-wiring tables
Fourteen tables are model state — who exists and who may do what. The other
five are runtime wiring: where a decision's object metadata and attribute bags
are read from. They are the database-backed home for a seed document's
connections:, providers:, field_types: and attribute_providers: sections,
so a second instance can boot with no seed file and decide identically.
Those are four of the document's six wiring sections. objects: and
attributes: stay local to the instance whose file lists them, because they carry
the data rather than a pointer to it — see
The file is not the only home for wiring
and aperture wiring for the command surface over these tables.
| Table | Holds | Key |
|---|---|---|
apt_wiring_connections | one row per connection name, and nothing else | name |
apt_wiring_providers | a providers: entry: kind, connection, get_one, get_all, id_column, ttl, max_size | object_type |
apt_wiring_provider_references | a provider's references: map, flattened | (object_type, field) |
apt_wiring_field_types | the field_types: section, flattened | (object_type, field) |
apt_wiring_attribute_providers | an attribute_providers: entry, plus the optional declared key set | subject (the slot) |
What is absent from them is the load-bearing part:
- No column can carry a secret — no DSN, no password, no credential, not even
the
dsn_env:variable name. A connection's credentials are a per-instance fact each instance resolves for itself, so there is nothing here to leak and no credential in a backup of this database. That is why the connection table holds names and nothing else: the name is the only part a provider entry refers to. - There is no
pathcolumn. A filesystem path is machine-local, and a stored one is a guess about another instance's filesystem. File-backed providers stay a local-file affordance. - A
ttlis stored as the Go duration text the operator wrote ("30s"), never as an integer, because wiring read back out has to be re-pushable byte for byte. A duration is not an instant: the nanosecond encoding above governscreated_atandupdated_at, and nothing else in these tables. declared_keystells "not declared" apart from "declared empty". It holds the attribute keys a shared slot guarantees, as a JSON array of names.''means not declared;'[]'means declared empty. They are different answers.
The wiring is read and written whole
The five tables have one write method, and it replaces all of them:
ReplaceWiring(ctx, model.WiringSet) error // all of it, or none of it
GetWiring(ctx) (model.WiringSet, error) // one consistent snapshot
There is deliberately no per-row Put/Delete pair. Wiring is only meaningful
whole — a provider entry naming a connection the manifest does not list is not
half-valid wiring, and an instance that booted against a set written half-way
would build its registries missing exactly the entries whose write failed, while
reporting nothing. So ReplaceWiring validates the entire set first, removes
every row of all five tables, and writes the new set in one transaction: a
refusal at any point leaves the tables exactly as they were. Pushing a zero
WiringSet clears the wiring.
GetWiring reads the four sections inside one transaction, for the mirror-image
reason: a boot that read the provider list from before a push and the field-type
list from after it would build a registry that never existed in the database at
any instant. A database nothing has been pushed to answers with an empty
WiringSet — IsEmpty() reports it, and it is not an error. That is the state
that tells a booting instance to fall back to its local seed file.
Alongside those two there are per-section reads (ListWiringConnections,
ListWiringProviders, ListWiringFieldTypes,
ListWiringAttributeProviders) and per-entity reads returning
APERTURE_NOT_FOUND for an absent key. Every read returns canonical order —
connections by name, providers by object type, a provider's references by field,
field types by object type then field, attribute slots by subject — because a
read back that has to be re-pushable byte for byte cannot come back in map order.
Two refusals are worth knowing apart. A malformed set (an empty key, a negative
max_size, the same object type declared twice) is APERTURE_INVALID_INPUT: a
collision inside one pushed set is a bad push, not a database failure. A provider
entry serving an object type the model does not have is
APERTURE_STORAGE_CONSTRAINT, from the real foreign key on
apt_wiring_providers.object_type.
Account stamping is enforced in the queries
Cross-account isolation is a data-layer guarantee, not just a service-layer
convention. Every grant carries an AccountID, and the account-scoped queries —
ListGrants(account) and the engine's hot-path GrantsForSubjects(account, subjects) — mean a grant stamped to one account can never surface in another.
Decision-engine queries
Two methods exist specifically for the decision hot path:
GrantsForSubjects(ctx, account, subjects)— the engine expands a principal into its subject set (the principal, its roles, its groups) and asks for exactly the account-scoped grants bound to that set.GroupsForPrincipal(ctx, principal)— the group half of a principal's subject set.
IsMember(ctx, principal, account) is a tight existence check the engine uses to
enforce membership without materializing the full membership list.
Transactions
Atomic(ctx, fn) runs fn inside a transaction against a tx-scoped Storage,
committing when fn returns nil and rolling the whole batch back on any error.
All three backends give real atomicity — the SQL backends via
BEGIN/COMMIT/ROLLBACK, the in-memory backend via a staged snapshot committed
only on success. It is the primitive the bulk grant/revoke endpoints and template
apply build on. fn must use the tx handed to it (not the outer Storage);
a nested Atomic flattens into the current transaction, so an outer rollback
still covers everything — and in Postgres that flattening is also what stops the
pool deadlocking against itself.
err := store.Atomic(ctx, func(tx model.Storage) error {
if err := tx.PutGrant(ctx, g1); err != nil {
return err // rolls back g1 and anything else in the batch
}
return tx.PutGrant(ctx, g2)
})
The audit trail
The same Storage seam carries the append-only audit trail:
AppendAudit (the only single-event write), QueryAudit (newest-first, filtered),
and PruneAudit (bulk retention delete). There is no update and no single-event
delete, so a recorded event cannot be silently altered — and, as above, no foreign
key ties a record to entities it must outlive.
Construction
mem := memory.New() // in-memory
db, _ := sqlite.Open("aperture.db") // durable file
// db, _ := sqlite.OpenMemory() // ephemeral SQLite (tests)
pg, _ := postgres.Open("postgres://user:pw@host:5432/db", postgres.WithSchemaFromEnv())
_ = db.Setup(ctx) // once, before any other call
From the CLI, --store picks the backend:
--store | Backend |
|---|---|
| empty | in-memory |
postgres://… or postgresql://… | PostgreSQL |
| anything else | SQLite at that path |
Note that a libpq keyword DSN (host=… dbname=…) still selects SQLite, even
though the driver would accept it. --store's other value is a file path, and
sniffing for keyword pairs would mean a path containing = started opening
databases over the network.
Choosing a PostgreSQL schema
APERTURE_POSTGRES_SCHEMA (or postgres.WithSchema) pins Aperture's tables into
a named schema. Unset means "use whatever the connection's search_path
resolves to" — the zero-configuration path, and the reason every table Aperture
owns is prefixed apt_: that prefix is what makes an unqualified deployment safe
in a database shared with a host application, so pinning a schema is a choice
about tidiness and grants rather than a requirement.
Three properties surprise people, and all three are deliberate:
- The name is used verbatim and case-sensitively.
APERTURE_POSTGRES_SCHEMA=Apertureaddresses a schema literally namedAperture— the oneCREATE SCHEMA "Aperture"makes, not the oneCREATE SCHEMA Aperturemakes, which isaperture. Aperture applies no folding rule to an environment variable. - The value is not trimmed. A stray space is a boot failure that names the whitespace, rather than a silent success on a value you did not quite type.
- The name is validated before anything connects. It must match
\A[A-Za-z_][A-Za-z0-9_]*\zand be at most 63 bytes; anything else isAPERTURE_CONFIG_INVALIDat boot. It is the one piece of configuration interpolated into SQL text, because SQL has no bind parameter in an identifier position.
It is configured by environment rather than by a flag because it is a property of the deployment, not of an invocation: a per-command flag would be a way to write half a model into the wrong namespace.
One deployment note: CREATE SCHEMA IF NOT EXISTS x fails with SQLSTATE 42501
for a role that lacks CREATE on the database, even when x already exists.
Grant CREATE, or pre-create the schema and use a role that only needs table
privileges within it.
A schema break is a hard break
Aperture ships no migration tool, no schema versioning, and no user_version
pragma. Setup applies an embedded schema of CREATE ... IF NOT EXISTS
statements: on a database that already has Aperture's current tables it creates
nothing and changes nothing.
That idempotence is also why it cannot upgrade. SQLite is dynamically typed, so a table an older build created keeps its old column types and its old values even where the current schema declares something different — the engine raises no objection, and the mistake would surface much later, as a scan error or a wrong timestamp, far from its cause.
So the SQLite backend's Setup inspects an existing database first and refuses
one it cannot read, returning APERTURE_STORAGE_SCHEMA_INCOMPATIBLE naming
what it found and the remedy. It looks for:
- declared column types — a timestamp column not declared
INTEGER; - stored value types — a column declared
INTEGERwhose rows still hold text, which SQLite permits and which a type declaration alone would miss; - retired column names —
ts_nanos, the audit log's old timestamp column, nowoccurred_at; - the older, unprefixed schema — databases predating the
apt_table prefix have noapt_table at all, so nothing above would notice them and Aperture would come up empty beside the operator's data. These are recognized by fingerprinting Aperture's own column sets, not by table name, so a host schema that merely owns a table calledaccountsorrolesis left alone.
A fresh database and an up-to-date one both proceed normally.
The remedy is always the same: move or delete the old database, let Setup
create a fresh one, and re-seed it. Export first with the
seed/portability tooling if you need the contents. The guard is
SQLite-only — the Postgres backend is newer than every schema change it would
look for, so no database predating it can exist.
Two processes booting at once
CREATE ... IF NOT EXISTS is not race-free in PostgreSQL: two sessions can both
pass the existence check and the loser fails on a unique violation. Two servers
booting against one database is an ordinary deployment, so the Postgres Setup
takes a transaction-scoped advisory lock around the whole schema script. SQLite,
being single-writer, does not need one.
A note on ordering when you seed
Foreign keys mean writes have an order: a parent must exist before a child
references it. The in-memory backend enforces nothing, so a document that seeds in
the wrong order will load cleanly there and fail against SQLite or PostgreSQL. If
you author a loader or change seed.Document.Apply's write order, exercise it
against a real file-backed database, not the in-memory store.
The same applies to teardown, in reverse — and there is one case worth naming
because it looks like a bug and is not. Deleting an account requires deleting
its grants first, including the admin's own. An admin whose grant is stamped to
a concrete account therefore revokes its own authority partway through the
teardown, and the next step fails with APERTURE_AUTHZ_DENIED rather than a
constraint error. Account teardown only works end to end for an admin holding a
model.AccountWildcard grant.
Related
- The decision engine — the hot-path reader of
GrantsForSubjects/GroupsForPrincipal. - The RBAC model — the entities
Storagepersists. - Seed & portability — the declarative loader/exporter over
Storage. - Audit trail — the append-only trail this interface carries.
- Error codes —
APERTURE_STORAGE,APERTURE_STORAGE_CONSTRAINT,APERTURE_STORAGE_SCHEMA_INCOMPATIBLE,APERTURE_NOT_FOUND,APERTURE_ACTION_UNDECLARED,APERTURE_CONFIG_INVALID.
Error Codes
Every failure surfaced by Aperture is an APERTURE_* coded error. Each code carries a canonical message and, where operator action is meaningful, one or more fixup hints. This page is generated from the error Registry in errors/codes.go.
| Code | Message | Fixups |
|---|---|---|
APERTURE_ACTION_UNDECLARED | action is not declared on the object type | Add the action verb to the object type's declared action set, or grant a verb the type already declares. List the object type's actions to see the validated verb set. |
APERTURE_ATTRIBUTE_PROVIDER_FETCH | attribute provider returned an error | Inspect the wrapped cause for the underlying provider failure. Return APERTURE_NOT_FOUND from the provider for a key it does not know, so an unknown subject stays distinguishable from an unreachable directory. |
APERTURE_ATTRIBUTE_PROVIDER_INVALID | attribute provider registration or attribute key is invalid | Register a non-nil provider, and at most one per LAYER: Register fills a slot's shared layer and RegisterLocal its local one, so a slot holds two and a third is refused. A duplicate within a layer is refused rather than replaced, so one directory cannot silently shadow another. Declare a key set on a slot's SHARED layer only (Register with provider.WithDeclaredKeys): a declared key is reserved to the layer that declared it, so a local declared set would reserve keys against the deployment's own directory. A local layer takes cache options only. Declare each attribute key at most once within a provider. Fetch with a real key: a principal id for the user and machine slots, an account id for the account slot. An empty key names nobody. Resolve the account wildcard "*" to a concrete account before fetching attributes; it is never a legal attribute key. |
APERTURE_ATTRIBUTE_PROVIDER_UNREGISTERED | no attribute provider is registered for the slot | Register an AttributeProvider for the slot before fetching its attributes. Check that the principal's kind maps to the slot you wired: a machine principal reads the machine slot, not the user slot. Deployment genuinely has no subjects of this kind? Then nothing should be fetching that slot — fix the caller rather than registering an empty provider. Seeing this from a decision? You should not: a Check/Enumerate/Explain against an unwired slot evaluates the floor bag and decides. This code reaches you only from a DIRECT attribute read (Fetch or Enumerate on the registry), so the caller to fix is that reader, not the decision path. |
APERTURE_ATTRIBUTE_SLOT_UNKNOWN | not an attribute slot | Use one of the three declared slots: user, machine, or account (provider.AttributeSlotUser / AttributeSlotMachine / AttributeSlotAccount). Converting a principal kind that arrived as a bare string? Cross over with provider.ParseAttributeSlot so an unknown kind fails at the conversion instead of resolving to an empty attribute bag. The slot set is closed on purpose — it names the parties a decision has. Model a further distinction as a FIELD in the bag, not as a fourth slot. |
APERTURE_AUTHZ_DENIED | the actor lacks the admin authority tier required for this mutation | Schema mutations (permission types, roles, object-types, providers, templates, rules) require system-admin authority: an allow grant on the admin action whose object covers system:. Grant and delegation mutations require account-admin authority in the TARGET account: an allow grant on the admin action whose object covers account:<acct>/admin:. Account-admin authority is confined to its own account; obtain authority in the account the mutation targets, or hold a broader (e.g. **) grant. |
APERTURE_BOOT | aperture failed to start | Check the APERTURE_* environment variables and any --config file. Confirm the storage backend (memory or sqlite) is reachable. |
APERTURE_CONFIG_INVALID | configuration is invalid | Validate the YAML config and APERTURE_* env vars against the docs. Enumerating an attribute slot that was declared without get_all: that slot is fetch-only by design, so add a get_all statement selecting a bare id, or read the slot through a fetch alone. |
APERTURE_DELEGATION_DENIED | the delegator may not bestow this grant | Bestow only grants that are a subset of your own effective allow grants in the account (same action and scope strategy, an equal-or-more-specific object pattern). Confirm you hold a 'may delegate' right whose object pattern covers the grant's object. Bestow grants only within an account you are a member of; cross-account bestowal is rejected. |
APERTURE_DELEGATION_NOT_DELEGATABLE | the permission is not flagged delegatable | Set Delegatable on the permission definition to allow it to be bestowed. |
APERTURE_ENTITY_UNMANAGED | this deployment does not manage the entity kind the write targeted | Set the switch for the kind named in the message — APERTURE_MANAGE_ACCOUNTS, APERTURE_MANAGE_PRINCIPALS, or APERTURE_MANAGE_MEMBERSHIPS — to true (the default), then RESTART aperture: the switches are read once at startup and never re-read. The three switches are independent; turning one on does not affect the others, so enable only the kind you meant to hand back to Aperture. Leaving a kind unmanaged is usually deliberate — those records are mastered by an upstream system. Make the change there and let it flow in, rather than flipping the switch. This is not a permission problem: no grant, role, or admin tier lifts it, and it refuses a system-admin exactly as it refuses anyone else. |
APERTURE_IDENTITY_INVALID | object identity is malformed | Use type:id segments joined by '/', e.g. account:acme/project:atlas/document:42. Ensure no segment is empty and every segment carries a ':' with a non-empty type and id. Remove illegal characters; types and ids allow letters, digits, and -._~@+ only ('*' marks a wildcard in patterns). |
APERTURE_IMPERSONATION_DENIED | the operator may not impersonate this target | Impersonate only within an account both the operator and the target are members of; cross-account impersonation is refused. Confirm the operator holds an impersonation right (augment or become) whose object pattern covers the target's identity. Become mode requires the stronger become right; an augment right alone cannot become a target. |
APERTURE_IMPERSONATION_EXPIRED | the impersonation session has expired | Start a fresh impersonation session; sessions are time-boxed and expire automatically. |
APERTURE_INVALID_INPUT | input failed validation | Re-check the request shape against the command or API contract. |
APERTURE_INVALID_TOKEN | the presented bearer credential failed verification | Confirm the token is a well-formed JWT signed by the configured issuer's keys. Check the token issuer and audience match APERTURE_OIDC_ISSUER and APERTURE_OIDC_AUDIENCE, and that it has not expired. For a parsec adapter, confirm the token was minted by the broker sharing the configured keyring/secret. |
APERTURE_METADATA_INVALID | object metadata violates the metadata value model | Make each field a scalar, a []any of scalars, or a map[string]any whose values are scalars, scalar arrays, or one further object level. Replace an array of objects with a scalar array (e.g. a list of ids) — arrays of objects are rejected at any position. Flatten a value that nests past the depth cap, or raise provider.ValueLimits.MaxDepth for the loader. Shorten a value over the per-value size cap, or raise provider.ValueLimits.MaxBytes for the loader. |
APERTURE_NOT_FOUND | the referenced entity was not found | Confirm the identifier exists in the current account scope. |
APERTURE_PROVIDER_FETCH | object provider returned an error | Inspect the wrapped cause for the underlying provider failure. Return APERTURE_NOT_FOUND from the provider for an object that does not exist. |
APERTURE_PROVIDER_INVALID | object provider registration is invalid | Register a non-nil provider under a non-empty object-type key. Register each object type at most once; check for a duplicate registration. |
APERTURE_PROVIDER_REFERENCE_INVALID | a declared object reference is not usable | Register a provider for the target object-type: a references: entry may only point at a type this registry serves. Declare each field at most once per object-type; several fields may point at the same target, but one field has one target. Declare the reference on the HOLDING side — the type whose provider actually returns the field — because that is the only side with a value to resolve. Resolving a field means declaring it first: reg.DeclareReference("dataset", "current_brands", "brand"), or a references: entry in the provider's seed block. |
APERTURE_PROVIDER_REFERENCE_MISMATCH | a reference field's value does not identify an object of its declared target type | Store FULL canonical identities in a reference field ('brand:1', 'account:acme/brand:1'), not bare primary keys — compose them where the data is loaded, e.g. SELECT 'brand:' || b.id. Make the field a string or a list of strings; a number, a map, or a list holding anything but strings cannot be an identity. Check the declared target against the values the field actually carries: an identity whose terminal segment type is not the declared type is rejected rather than skipped. |
APERTURE_PROVIDER_UNREGISTERED | no object provider is registered for the object type | Register an ObjectProvider for the object type before fetching its metadata. Confirm the object identity's terminal segment type matches a registered provider key. Filtering an enumeration by metadata fields? Build the engine with engine.WithMetadata(registry) — the same provider registry the scope lister uses — or drop the field predicates. Searching object metadata by name? Search ALWAYS needs engine.WithMetadata(registry); unlike a field predicate it is not optional, and an empty result would read as "no access" rather than as the misconfiguration it is. |
APERTURE_RULE_EVAL | rule evaluation failed | Inspect the wrapped cause for the underlying evaluation failure. Ensure the rule expression yields a boolean for the supplied context. |
APERTURE_RULE_INVALID | rule AST is malformed | Give each logical node the right child count: and/or take two or more, not takes exactly one. Give every comparison a left and right operand, and every literal a scalar value. Write variable references as dotted identifier paths, e.g. object.classification. |
APERTURE_RULE_NOT_FOUND | the referenced rule was not found | Confirm the rule reference exists in the configured rule source. |
APERTURE_RULE_TYPE_ERROR | rule failed expression type checking | Compare compatible types and make the rule evaluate to a boolean. Call only functions registered with the rules engine. |
APERTURE_RULE_UNDECLARED_ATTRIBUTE | rule reads an attribute key the wiring does not declare | Add the key to declared_keys: on the attribute_providers: entry for the slot the message names, then push the wiring again. Or stop reading the key in the rule: a key no shared entry declares is served by at most one instance's local layer, so a rule over it decides differently per instance. Read individual keys rather than the whole bag — a bare principal or account reference reads every key the bag happens to carry, which no declared set can cover.A slot that declares no key set is not enforced at all; remove declared_keys: from the entry to opt that slot back out. |
APERTURE_RULE_UNKNOWN_VARIABLE | rule references an unknown variable | Reference variables under a known context root: object, principal, account, or action. Check for a typo in the variable's root segment. |
APERTURE_SCOPE_INVALID | scope strategy reference is malformed | Use 'strategy' or 'strategy;param=value' form, e.g. inclusive;ids=account:acme/document:42. Give an inclusive/exclusive strategy an 'ids' list or a 'rule' reference; implicit takes no configuration. |
APERTURE_SCOPE_LISTER_UNCONFIGURED | scope enumeration requires an object lister that is not configured | — not applicable |
APERTURE_SCOPE_RULE_UNCONFIGURED | scope rule path requires a rule evaluator that is not configured | — not applicable |
APERTURE_SCOPE_UNKNOWN_STRATEGY | scope strategy is not registered | Use a built-in strategy (literal, implicit, inclusive, exclusive) or register the custom key with the scope registry. |
APERTURE_SQL_PROVIDER_AMBIGUOUS | SQL provider's fetch statement returned more than one row for one key | Filter the fetch statement on a unique or primary key so one identity — or one subject id — selects at most one row. A join that fans out is the usual cause; aggregate or de-duplicate the fanned-out side instead of adding LIMIT 1, which would make the metadata depend on an unspecified row order. |
APERTURE_SQL_PROVIDER_CONNECTION | a declared database connection could not be resolved into a live pool | Set the environment variable named by the connection's dsn_env: to a non-empty DSN before starting the process. Check that every kind: sql provider entry's connection: matches a name declared in the top-level connections: block. Give pool settings valid values: conn_max_lifetime and query_timeout are Go durations ("30m", "5s"), and query_timeout must be positive — there is no 'no timeout' setting. Verify the DSN's host, port, database, and credentials by connecting with psql; the driver's message is redacted here because a DSN parse failure commonly echoes the password. |
APERTURE_SQL_PROVIDER_DSN_LITERAL | a declarative database connection carries a literal dsn instead of dsn_env | Replace the connection's dsn: key with dsn_env: naming the environment variable that holds the DSN. Export the DSN in the process environment (or a .env file the deployment loads) rather than writing it into the seed file. Rotate the credential if a literal DSN was ever committed — the seed file is a version-controlled artifact. |
APERTURE_SQL_PROVIDER_QUERY | SQL provider could not run its statement | Inspect the wrapped driver error for the underlying database failure. Check the statement's placeholder count: a fetch statement binds exactly one parameter — the identity's terminal segment value for an object provider, the bare subject id for an attribute provider. Use the placeholder syntax your engine speaks — Aperture passes placeholders through untouched and never rewrites $1 to ?. Confirm the database is reachable and the connection's role can read the table; raise Config.Timeout if the statement is legitimately slow. |
APERTURE_SQL_PROVIDER_ROW_IDENTITY | SQL provider could not turn a row's id column into a usable key | Object provider: compose the full identity in the get-all statement's id column — SELECT 'brand:' || b.id AS id — because a bare primary key is not an identity and Aperture supplies no template. Attribute provider: select the BARE subject id — SELECT u.id AS id — and never 'user:' || u.id, which is a legal opaque key that no principal id will ever match, so the slot enumerates and then answers nothing. Name the key column id, or set the provider's id column to the alias the statement actually uses. Make the id column textual and never NULL: cast a numeric or uuid key with ::text before concatenating it. Check that the identity's terminal segment type is the object-type this provider is registered under; a 'brand:1' row served by the 'dataset' provider is rejected rather than cached. |
APERTURE_SQL_PROVIDER_SCAN | SQL provider could not read a row into metadata | Give every selected expression a name, and alias duplicates: each result column becomes a metadata field keyed by its column name. Cast or serialise a column whose Go type the provider does not map (the driver value's type is named in the error, alongside the types that are mapped). A []byte column is decoded as JSON, never as a string: wrap an array in to_jsonb(...), and cast a numeric, uuid, or bytea to ::text, ::float8, or encode(...) in the statement. A list-valued field only arrives as a list when the statement casts it — SELECT to_jsonb(tags) AS tags, not SELECT tags, which yields the raw array literal as a string that silently matches nothing. |
APERTURE_STORAGE | the storage backend returned an error | Inspect the wrapped cause for the underlying storage failure. |
APERTURE_STORAGE_CONSTRAINT | the database refused the write because it would break referential integrity | Delete the rows that reference the record first: a principal's memberships and group memberships, a permission's role assignments and grants, a role's principal assignments, an object type's permissions, an account's memberships and grants, and the grants that name a principal, role, or group as their subject. Creating a record? Create what it references first — a permission needs its object type, a membership and a group member need their principal, a grant and a role assignment need their permission, and a membership and a grant need their account. Refused on a grant's subject? subject_kind chooses the table subject_id must exist in — principal, role, or group. A grant naming a role id under kind "group" is refused even though that id exists elsewhere. Refused on an account id? It must name a real account, or be exactly "" (the all-accounts wildcard). "" is a sentinel, not an account: it needs no account record and cannot be given one. Ids are immutable in Aperture: to change one, create the new record, move the children, then delete the old one — an UPDATE that moved a key is refused rather than silently re-parenting. Seeing this from Setup rather than a write? The connection is not enforcing foreign keys. Open the store with sqlite.Open, which forces _pragma=foreign_keys(1) into the DSN whatever the caller passed. |
APERTURE_STORAGE_SCHEMA_INCOMPATIBLE | the existing database was written by an incompatible build and cannot be upgraded | Recreate the database: move or delete the old file, start Aperture so Setup builds the schema fresh, then re-seed it. Point the store at a new, empty database if you need to keep the old file for reference. Do not expect an in-place upgrade: Aperture ships no migration tool and no schema versioning, so a schema break is a hard break by design. |
APERTURE_TEMPLATE_INVALID | the provisioning template is malformed | Give the template a non-empty name, a version of at least 1, and at least one grant. Declare every parameter a grant references; write references as ${name} with a declared parameter. Give each template grant a valid subject, a permission id, an allow/deny effect, and a non-empty object pattern. |
APERTURE_TEMPLATE_PARAM | the template apply supplied invalid parameters | Supply a value for every parameter the template declares, and no parameters it does not. A segment-typed parameter value must be a legal identity component: letters, digits, and -._~@+ only. |
APERTURE_UNAUTHENTICATED | the request could not be resolved to a known principal | Present a credential: send an Authorization: Bearer <token> header. With the dev/static authenticator the bearer IS the principal id; send a non-empty value. Confirm the verified token carries the configured principal claim (APERTURE_AUTH_PRINCIPAL_CLAIM, default 'sub'). |
APERTURE_UNIMPLEMENTED | this surface is not yet implemented | — not applicable |
APERTURE_WIRING_CONNECTION_UNDECLARED | a pushed entry names a connection the wiring's connections: manifest does not declare | Add the connection named in the message to the document's connections: block, with dsn_env: naming the environment variable that holds its DSN. Or fix the entry's connection: to match one of the declared names the message lists — one connections: entry is one pool, so a typo opens no pool rather than a second one. Only the NAME is shared: every instance resolves that connection's DSN and pool settings from its own environment, so the manifest is a list of names and nothing else. |
APERTURE_WIRING_CONNECTION_UNROUTED | the shared wiring declares a connection name this instance has no route for | Export the environment variable the message names — APERTURE_CONNECTION_<NAME>_DSN — with this instance's DSN for that connection. It is the conventional route and needs no seed file. Or declare a connections: entry under the same name in this instance's --seed file, with dsn_env: naming a variable of your choosing; a local entry is that name's route, not a competing declaration, and it also carries the pool sizes and query_timeout. Or, in a Go host, supply seed.WithConnectionOpener and build the pool for that name yourself — the one seam a host needs, and the only one that never reads a DSN from the environment. Check which names this deployment expects with aperture wiring show --store <dsn>: the shared tables carry the connection NAME and nothing else, so every instance must supply its own route for each one.Do not work around it by removing the entry that uses the connection: an object provider that cannot reach its database yields no metadata, and an attribute provider that cannot yields a nil bag — which widens an exclusive grant rather than denying. |
APERTURE_WIRING_EMPTY_PUSH | the document being pushed declares no shared wiring at all, and a push is a REPLACE | Check the --seed path first: the likeliest cause is the wrong file, and the second likeliest is a mistyped section key (provider: for providers:, field_type: for field_types:), which the seed reader does not refuse — it simply reads no section.Confirm what the document declares with aperture wiring push --seed <file> --store <dsn>'s summary on a store you do not mind replacing, or read the file's four shared sections directly: connections:, providers:, field_types:, attribute_providers:.If retiring the deployment's wiring really is the intent, pass --allow-empty. Every instance then builds its wiring from its own --seed file again, and an instance with no --seed file builds NONE — an empty object registry and an empty attribute registry, which still starts. Retiring an attribute slot is the dangerous half: an unregistered slot answers with an empty bag, and a rule that EXCLUDED on an attribute it can no longer see stops excluding, so the grant widens with nothing in a verdict, a trace or a note to say so. Prefer pushing a document that keeps the slots and changes what you meant to change. aperture wiring show --store <dsn> prints what is deployed right now, and aperture wiring pull --out <file> writes it out as a document you can edit and push back. |
APERTURE_WIRING_KIND_UNSHAREABLE | a pushed entry selects a kind that cannot be shared wiring | Replace the kind: csv entry named in the message with kind: sql reading through a connections: entry, so every instance reaches the same data without a shared filesystem. Or leave that entry out of the pushed wiring and keep it in the LOCAL seed document, where the path belongs to the instance that reads it. A csv entry's only data source is a filesystem path; the shared-wiring schema has no path column, because a relative path resolves against the seed file's own directory and an absolute one is a guess about the other host's disk. On a BOOT this refusal means the row is already deployed — written by hand, or by a build that predates the check. Re-push a wiring document without it ( aperture wiring push), or read the same data through kind: sql; there is no path column to add a path to. |
APERTURE_WIRING_LOCAL_COLLISION | the local seed file declares an object type or attribute slot the shared wiring already declares | Delete the local declaration named in the message: with wiring rows present the database is authoritative, and the local file may only ADD an object type or slot the database never declared. Or remove the shared declaration instead — push a wiring document that omits it ( aperture wiring push --store <dsn> --seed <file>) — if the local one is the wiring you actually want the fleet to use.Check which side declares what with aperture wiring show --store <dsn>, then read the same section of this instance's --seed file.A host registering its own providers in Go gets the same refusal from the registry as APERTURE_PROVIDER_INVALID: the rule is about the registry, not about which syntax declared the entry. |
APERTURE_WIRING_NOTHING_DEPLOYED | the store has no shared wiring deployed, so there is nothing to pull | Check the --store DSN first: a typo names a database that does not exist yet, Setup creates it empty, and an empty read is exactly what that looks like. If the DSN is right, nothing has been pushed to this deployment yet — run aperture wiring push --seed <file> --store <dsn>, and every instance sharing the database will read it.aperture wiring show --store <dsn> describes an empty store without refusing, which is the command to use when you only want to know whether anything is deployed.A pull is refused rather than writing an empty document because that document, pushed back, would replace the deployment's wiring with nothing. |
APERTURE_WIRING_NO_MODEL_STATE | the target store holds no model state, so there is nothing for the pushed wiring to be wiring for | Apply model state to this store first — aperture import --store <dsn>, aperture serve --store <dsn> --seed <file>, or the mutation commands — and then push the wiring.Check the --store DSN: a typo names a database that does not exist yet, Setup creates it empty, and the wiring would land somewhere no instance reads. Two instances sharing wiring must share the MODEL too; wiring says where object metadata and attribute bags are read from, not who exists. |
APERTURE_WIRING_OBJECT_TYPE_UNKNOWN | a pushed provider entry serves an object type the model does not declare | Declare the object type named in the message in the store's object_types: (apply the model state, then push the wiring again). Check the spelling against aperture list object-type --store <dsn>: the wiring's object_type: must equal the object type's name exactly.Field-type declarations are exempt — they may name a type whose objects a local seed lists inline — so only providers: entries need a row. |
APERTURE_WIRING_OUTPUT_EXISTS | the --out path already exists and would be overwritten | Write to a new path and compare the two files yourself — the existing file is usually the version-controlled document the pull is meant to be diffed against. Pass --force to replace the file deliberately. Nothing was read from the store and nothing was written: the path is checked before the wiring is fetched, so a refusal here leaves both the file and the deployment untouched. |
APERTURE_WIRING_REFRESH_FAILED | a background re-read of the shared wiring failed, so this instance is still deciding from the wiring it has | Nothing is broken about this instance's decisions: it kept the last wiring it successfully read and is still answering. What it has lost is the ability to notice a aperture wiring push, so treat it as a deployment that has drifted, not as an outage.Read how long it has been stale with the system-admin-gated WiringPosture read (the WiringPosture RPC), which reports the duration, the failure count and the code — an alarm minutes old on a restarting database is ordinary, and the same alarm hours old is a fleet enforcing policy somebody already retired.The alarm normally carries the underlying failure's OWN code and fixups; follow those first. Seeing this code itself means nothing underneath it was coded, which is worth reporting. Check the store is reachable from this host and that its wiring tables match this build ( aperture wiring show --store <dsn>): an unreachable store and a schema from an older build are the two commonest causes.If the push added a connection name, every instance needs its own route for it — export APERTURE_CONNECTION_<NAME>_DSN, or declare the name in this instance's --seed file. See APERTURE_WIRING_CONNECTION_UNROUTED. Restarting the instance is the way to force the matter, and it is safe: a boot re-reads the shared wiring from scratch and REFUSES to start on wiring it cannot build, rather than degrading. |
APERTURE_WIRING_RESTART_REQUIRED | the deployed wiring changes this instance's connection name set, which is fixed for the life of a process | Restart this instance to adopt the push. The connection NAME SET is resolved once, at boot, because a route — which server, which credential, how big a pool — is a per-instance fact the shared tables carry no column for. For each name the message says the push ADDS, supply this instance's route first: export APERTURE_CONNECTION_<NAME>_DSN, or declare a connections: entry under the same name in this instance's --seed file, or supply seed.WithConnectionOpener in a Go host. Then restart. For each name the message says the push DROPS, nothing needs supplying: the pool stays open and this instance goes on deciding through the wiring it has until it is restarted. Nothing else in the push was applied either — a push is adopted whole or not at all — so the providers, field types and attribute providers pushed alongside the connection change are still outstanding and land on the restart. Check what the deployment expects with aperture wiring show --store <dsn>; the shared tables carry the connection NAME and nothing else, so every instance supplies its own route for each one. |
Command-Line Reference
Audience: operators and integrators driving Aperture from a shell.
aperture — Fine-grained access control engine. This page is generated from the urfave/cli command tree in internal/cli (cli.NewApp); every command, subcommand, and flag below is read from the live definitions.
Global flags
aperture declares no persistent global flags. The commonly shared options — --seed, --store, --account, and --principal (the acting principal on mutations, sourced from APERTURE_PRINCIPAL) — are defined per command and appear in each command's flag table below.
Commands
| Command | Summary |
|---|---|
attributes | Inspect the attribute directories a seed wires, read one, or drop cached bags |
bestow | Bestow (delegate) a grant you hold to another principal |
bulk | Provision or deprovision many grants in one transactional call |
check | Decide whether a principal may take an action on an object |
delete | Delete an entity (object-type|permission|principal|role|group|account|grant|membership) |
enumerate | List the objects a principal may act on |
explain | Explain why a decision resolved the way it did |
export | Export the whole model to a single JSON/YAML state file (system-admin tier) |
get | Read one entity by id (object-type|permission|principal|role|group|account|grant) |
identifiers | List all valid instance ids of an object type from its provider |
impersonate | Start a time-boxed impersonation session (prints the session) |
import | Apply a JSON/YAML state file as an idempotent transactional upsert (system-admin tier) |
list | List entities of a kind (object-types|permissions|principals|roles|groups|accounts|grants) |
mcp | Serve the read-only Aperture MCP surface over stdio |
put | Create or update an entity (object-type|permission|principal|role|group|account|membership|grant) |
revoke | Revoke a grant you previously bestowed |
search | Rank the objects a principal may act on by a free-text name |
serve | Run the Aperture HTTP server |
template | Manage and apply provisioning templates |
wiring | Manage the shared wiring a deployment keeps in its database |
aperture attributes
Inspect the attribute directories a seed wires, read one, or drop cached bags
An attribute slot is a HOST DIRECTORY — the user table, the service-account
registry, the tenant catalogue — that a rule reads principal.* and account.*
out of. There are exactly three slots (user, machine, account) and each caches
the bags it has fetched, per slot, with its own ttl: and max_size:.
THE CACHE WINDOW IS A SECURITY PROPERTY, not only a tuning knob. Object metadata
that goes stale for a TTL is usually tolerable. An attribute bag is the ASKER'S
STANDING — the clearance, the department, the plan — so until a cached bag
expires, every decision about that subject keeps evaluating against access the
host has ALREADY TAKEN AWAY. Principals are the classic revoke case, and a
revocation that takes effect ttl: later is a revocation that has not happened
yet. What a slot's ttl: buys in fetch traffic it pays for in that delay.
So: pick a slot's ttl: for how fast its revocations must land, read back what a
deployment is actually running with aperture attributes slots, and close the
window on a specific subject with aperture attributes invalidate.
Reading a directory in bulk (query) and dropping cached bags (invalidate)
are SYSTEM-TIER operations: both require --principal holding system-admin
authority in --account, and a refusal returns nothing at all — no partial page,
no count, and nothing that tells an unauthorized caller which slots exist.
aperture attributes <command>
aperture attributes invalidate
Drop cached attribute bags so the next decision re-reads them (system-admin tier)
Drops cached bags, so the next decision about the affected subjects pulls fresh ones from the host directory. Three forms:
aperture attributes invalidate user --id alice one subject, one slot
aperture attributes invalidate user every bag in one slot
aperture attributes invalidate --all every bag in every slot
INVALIDATION IS A SECURITY CONTROL, NOT A PERFORMANCE KNOB. A cached attribute bag is the asker's standing, so a REVOKED CLEARANCE KEEPS AUTHORIZING until that bag expires: for the length of the slot's ttl:, every decision about that subject is made against access the host has already removed. Waiting the window out is not a remedy, it is the exposure. An operator who has just removed someone's access invalidates that subject here, and then the removal is true.
Scope: this drops the caches of THE PROCESS THAT RUNS IT. That makes it exact
for a host embedding Aperture (it is the operator's spelling of
provider.AttributeRegistry.Invalidate, which such a host calls the moment its
directory changes) and it makes a ONE-SHOT invocation self-contained: this
process starts with a cold cache and exits with it, so there is nothing here for
a stale bag to survive in. For a long-running aperture serve, the controls
that reach ITS cache are the slot's ttl: — set it to how fast that directory's
revocations must land — and a restart.
Requires --principal holding system-admin authority in --account: the result reports whether a bag was cached, which is a fact about who has recently been decided about, and clearing a large slot costs the next wave of decisions a provider round-trip each.
aperture attributes invalidate [options] <slot>
| Name | Aliases | Type | Default | Usage |
|---|---|---|---|---|
--account | — | string | — | active account (required for system-tier authority resolution) |
--all | — | bool | — | clear EVERY slot's cache; takes no <slot> argument and no --id |
--id | — | string | — | drop only this subject's cached bag (a bare principal or account id); omit to clear the whole slot |
--principal | — | string | — | authenticated principal performing the mutation (env: APERTURE_PRINCIPAL) |
--seed | — | string | — | path to a JSON/YAML seed model to apply on startup (when omitted: the embedded example for the in-memory store, and nothing at all for a --store DSN) |
--store | — | string | — | DSN for the backing store: a postgres:// or postgresql:// URL for PostgreSQL, any other value as a SQLite path (defaults to in-memory). Set APERTURE_POSTGRES_SCHEMA to place Aperture's tables in a named PostgreSQL schema; unset uses the connection's search_path |
aperture attributes query
Read a page of one attribute slot's directory (system-admin tier)
Returns up to --limit records of <slot> — user, machine, or account — as a JSON array of {id, attributes}, narrowed by attribute predicates.
THIS IS A SYSTEM-TIER READ. Unfiltered, it returns the head of the host's user
table, keys and bags together, so it requires --principal holding system-admin
authority in --account. A refusal returns NOTHING — no partial page, no count,
and no way to tell an empty slot from a full one or from an unwired one. Ask
aperture explain about your own authority if a refusal is unexpected.
--field and --fields-json narrow the result by ATTRIBUTE, on exactly the
predicate aperture enumerate applies to object metadata: predicates are ANDed,
a field the bag does not carry never matches, a list-valued field matches by
membership, and everything else matches by TYPED equality, so the string "5"
never matches the number 5. --field always sends a string; use --fields-json
when a number, bool, or list is genuinely meant:
aperture attributes query user --principal alice --account acme \
--field department=eng --fields-json '{"clearance":3}'
Both may be given together: --fields-json is merged FIRST and --field entries then override it by key.
A slot whose sql: entry declares no get_all: is FETCH-ONLY by design — it can answer the decision path without exposing the whole table to an enumeration — and this command reports that provider's coded refusal rather than an empty page.
aperture attributes query [options] <slot>
| Name | Aliases | Type | Default | Usage |
|---|---|---|---|---|
--account | — | string | — | active account (required for system-tier authority resolution) |
--field | — | string | — | object-metadata predicate as key=value, repeatable; the value is ALWAYS a string, so --field seats=5 matches the string "5" and never the number 5 (use --fields-json for that). Overrides --fields-json on a key collision |
--fields-json | — | string | — | object-metadata predicates as a JSON object, for values that are genuinely a number, bool, or list (e.g. '{"seats":5,"active":true,"tags":["a"]}'). Merged first; --field entries then override by key |
--limit | — | int | 0 | cap the number of returned records (<=0 means the default; the registry clamps it regardless) |
--principal | — | string | — | authenticated principal performing the mutation (env: APERTURE_PRINCIPAL) |
--seed | — | string | — | path to a JSON/YAML seed model to apply on startup (when omitted: the embedded example for the in-memory store, and nothing at all for a --store DSN) |
--store | — | string | — | DSN for the backing store: a postgres:// or postgresql:// URL for PostgreSQL, any other value as a SQLite path (defaults to in-memory). Set APERTURE_POSTGRES_SCHEMA to place Aperture's tables in a named PostgreSQL schema; unset uses the connection's search_path |
aperture attributes slots
List every attribute slot's registration layers, where each one is wired from, and its own cache settings
Prints one row per LAYER of each of the three slots — user, machine, account — with the source that layer is wired from, its cache freshness window, its cached-bag cap, and how many bags this process holds for the slot.
A SLOT HAS TWO REGISTRATION LAYERS AND EACH ONE CACHES ON ITS OWN DECLARATION.
An attribute_providers: entry — a pushed wiring row, or one in this instance's
seed file — is the slot's SHARED layer, and an attributes: block is its LOCAL
one. A fetch reads their merge and the shared layer wins every key both serve, so
nothing is discarded and the inline bags still contribute the keys the external
source does not carry. The layers are printed in precedence order, highest first.
THE TTL COLUMN IS THE REVOCATION WINDOW, AND THERE IS ONE PER LAYER. A cached bag
keeps authorizing until it expires, so ttl is the longest a removed clearance
can keep working — for the keys THAT LAYER answers. Two layers are two caches and
never an average: a shared directory on a five-minute window beside an inline
block that never expires leaves the keys only the inline block serves unbounded.
never means a bag, once fetched, is only dropped by eviction or by an explicit
aperture attributes invalidate — correct for a fixed inline block, dangerous
for a live directory.
THE SOURCE COLUMN NAMES THE PLACE, not just the kind. (shared wiring) is a row
in the deployment's database, put there by aperture wiring push and read by
every instance; (--seed file) is this instance's own document; (host) is a
provider the binary embedding Aperture registered in Go, which no document
describes. A slot with no source at all reads (unwired): that is not an empty
directory, it is a party this deployment declared nothing for, and every fetch
against it fails — leniently on the decision path, which means the rule sees only
the floor bag.
The cached column counts THIS process's cache for the whole SLOT, summed across
its layers — a subject both layers serve is held twice, because it is cached
twice — so it appears once, on the slot's first row, and reads - on the second
layer's. A one-shot invocation starts cold, so it reads 0; it is the number that
matters in a long-running aperture serve.
No actor is required: this reports the wiring this deployment is running — the seed file you passed, the shared wiring rows in the --store you named, and the configuration this process built from them. It contacts no provider and prints no subject key and no attribute value.
aperture attributes slots [options]
| Name | Aliases | Type | Default | Usage |
|---|---|---|---|---|
--seed | — | string | — | path to a JSON/YAML seed model to apply on startup (when omitted: the embedded example for the in-memory store, and nothing at all for a --store DSN) |
--store | — | string | — | DSN for the backing store: a postgres:// or postgresql:// URL for PostgreSQL, any other value as a SQLite path (defaults to in-memory). Set APERTURE_POSTGRES_SCHEMA to place Aperture's tables in a named PostgreSQL schema; unset uses the connection's search_path |
aperture bestow
Bestow (delegate) a grant you hold to another principal
aperture bestow [options]
| Name | Aliases | Type | Default | Usage |
|---|---|---|---|---|
--delegator | — | string | — | principal bestowing the grant (env: APERTURE_PRINCIPAL) (required) |
--file | — | string | — | path to a JSON grant body |
--json | — | string | — | grant body as inline JSON |
--seed | — | string | — | path to a JSON/YAML seed model to apply on startup (when omitted: the embedded example for the in-memory store, and nothing at all for a --store DSN) |
--store | — | string | — | DSN for the backing store: a postgres:// or postgresql:// URL for PostgreSQL, any other value as a SQLite path (defaults to in-memory). Set APERTURE_POSTGRES_SCHEMA to place Aperture's tables in a named PostgreSQL schema; unset uses the connection's search_path |
aperture bulk
Provision or deprovision many grants in one transactional call
aperture bulk <command>
aperture bulk grant
Apply many grants atomically (account-admin tier)
aperture bulk grant [options]
| Name | Aliases | Type | Default | Usage |
|---|---|---|---|---|
--account | — | string | — | active account (required for system-tier authority resolution) |
--file | — | string | — | path to a JSON array of grant bodies |
--json | — | string | — | a JSON array of grant bodies |
--principal | — | string | — | authenticated principal performing the mutation (env: APERTURE_PRINCIPAL) |
--seed | — | string | — | path to a JSON/YAML seed model to apply on startup (when omitted: the embedded example for the in-memory store, and nothing at all for a --store DSN) |
--store | — | string | — | DSN for the backing store: a postgres:// or postgresql:// URL for PostgreSQL, any other value as a SQLite path (defaults to in-memory). Set APERTURE_POSTGRES_SCHEMA to place Aperture's tables in a named PostgreSQL schema; unset uses the connection's search_path |
aperture bulk revoke
Delete many grants atomically (account-admin tier)
aperture bulk revoke [options] [<grant-id>...]
| Name | Aliases | Type | Default | Usage |
|---|---|---|---|---|
--account | — | string | — | active account (required for system-tier authority resolution) |
--grant | — | string | — | grant id to revoke (repeatable) |
--principal | — | string | — | authenticated principal performing the mutation (env: APERTURE_PRINCIPAL) |
--seed | — | string | — | path to a JSON/YAML seed model to apply on startup (when omitted: the embedded example for the in-memory store, and nothing at all for a --store DSN) |
--store | — | string | — | DSN for the backing store: a postgres:// or postgresql:// URL for PostgreSQL, any other value as a SQLite path (defaults to in-memory). Set APERTURE_POSTGRES_SCHEMA to place Aperture's tables in a named PostgreSQL schema; unset uses the connection's search_path |
aperture check
Decide whether a principal may take an action on an object
aperture check [options] <principal> <action> <object>
| Name | Aliases | Type | Default | Usage |
|---|---|---|---|---|
--account | — | string | "acme" | active account the decision is scoped to |
--enumerate-limit | — | string | — | maximum number of object ids one enumeration returns, and the ceiling a larger request limit is clamped down to. It configures the PROCESS, not the command: serve and every one-shot decision command honour the same value (a whole number greater than zero; default 1000; overrides APERTURE_ENUMERATE_LIMIT) (env: APERTURE_ENUMERATE_LIMIT) |
--seed | — | string | — | path to a JSON/YAML seed model to apply on startup (when omitted: the embedded example for the in-memory store, and nothing at all for a --store DSN) |
--store | — | string | — | DSN for the backing store: a postgres:// or postgresql:// URL for PostgreSQL, any other value as a SQLite path (defaults to in-memory). Set APERTURE_POSTGRES_SCHEMA to place Aperture's tables in a named PostgreSQL schema; unset uses the connection's search_path |
aperture delete
Delete an entity (object-type|permission|principal|role|group|account|grant|membership)
aperture delete [options] <kind> [<id>]
| Name | Aliases | Type | Default | Usage |
|---|---|---|---|---|
--account | — | string | — | active account (required for system-tier authority resolution) |
--account-id | — | string | — | membership account id (kind=membership) |
--principal | — | string | — | authenticated principal performing the mutation (env: APERTURE_PRINCIPAL) |
--principal-id | — | string | — | membership principal id (kind=membership) |
--seed | — | string | — | path to a JSON/YAML seed model to apply on startup (when omitted: the embedded example for the in-memory store, and nothing at all for a --store DSN) |
--store | — | string | — | DSN for the backing store: a postgres:// or postgresql:// URL for PostgreSQL, any other value as a SQLite path (defaults to in-memory). Set APERTURE_POSTGRES_SCHEMA to place Aperture's tables in a named PostgreSQL schema; unset uses the connection's search_path |
aperture enumerate
List the objects a principal may act on
Lists every object id under <pattern> that <principal> may take <action> on.
--field and --fields-json narrow that list by OBJECT METADATA. The predicate is typed: a field matches only when its value equals the wanted value AND is of the same kind, so the string "5" never matches the number 5. --field always sends a STRING; use --fields-json when a number, bool, or list is genuinely meant:
--field tier=premium --field current_brands=brand:Y
--fields-json '{"seats":5,"active":true,"tags":["public"]}'
Both may be given together: --fields-json is merged FIRST and --field entries then OVERRIDE it by key. Predicates are ANDed; a field the object does not carry never matches; a list-valued field matches by membership. Filtering happens before --limit.
--via restricts the list to what a DECLARED REFERENCE names — the other direction: --field asks "which datasets contain brand Y?", --via asks "which brands does dataset X list?". It is spelled <holder-identity>.<field>, where the field is everything after the LAST '.', and it is repeatable (edges are ANDed):
--via account:acme/dataset:x.current_brands
A holder you may not read yields an EMPTY list and no error, which is deliberate: "you may not see dataset X" and "dataset X lists nothing you may see" must not be tellable apart. Restriction, like filtering, happens before --limit.
--limit and --enumerate-limit are two different bounds. --limit is THIS REQUEST's
cap; --enumerate-limit is the DEPLOYMENT's ceiling, the same value aperture serve
honours, and it is what a --limit larger than it is clamped down to. A --limit of
zero or less asks for the ceiling.
aperture enumerate [options] <principal> <action> <pattern>
| Name | Aliases | Type | Default | Usage |
|---|---|---|---|---|
--account | — | string | "acme" | active account the enumeration is scoped to |
--enumerate-limit | — | string | — | maximum number of object ids one enumeration returns, and the ceiling a larger request limit is clamped down to. It configures the PROCESS, not the command: serve and every one-shot decision command honour the same value (a whole number greater than zero; default 1000; overrides APERTURE_ENUMERATE_LIMIT) (env: APERTURE_ENUMERATE_LIMIT) |
--field | — | string | — | object-metadata predicate as key=value, repeatable; the value is ALWAYS a string, so --field seats=5 matches the string "5" and never the number 5 (use --fields-json for that). Overrides --fields-json on a key collision |
--fields-json | — | string | — | object-metadata predicates as a JSON object, for values that are genuinely a number, bool, or list (e.g. '{"seats":5,"active":true,"tags":["a"]}'). Merged first; --field entries then override by key |
--limit | — | int | 0 | cap the number of returned object ids for THIS request, clamped down to the deployment's --enumerate-limit ceiling (<=0 means that ceiling, which is 1000 unless configured) |
--seed | — | string | — | path to a JSON/YAML seed model to apply on startup (when omitted: the embedded example for the in-memory store, and nothing at all for a --store DSN) |
--store | — | string | — | DSN for the backing store: a postgres:// or postgresql:// URL for PostgreSQL, any other value as a SQLite path (defaults to in-memory). Set APERTURE_POSTGRES_SCHEMA to place Aperture's tables in a named PostgreSQL schema; unset uses the connection's search_path |
--via | — | string | — | restrict the result to the objects a holder's declared reference field names, as <holder-identity>.<field> (e.g. --via account:acme/dataset:x.current_brands); repeatable, and several edges are ANDed. The FIELD is everything after the LAST '.' |
aperture explain
Explain why a decision resolved the way it did
aperture explain [options] <principal> <action> <object>
| Name | Aliases | Type | Default | Usage |
|---|---|---|---|---|
--account | — | string | "acme" | active account the decision is scoped to |
--enumerate-limit | — | string | — | maximum number of object ids one enumeration returns, and the ceiling a larger request limit is clamped down to. It configures the PROCESS, not the command: serve and every one-shot decision command honour the same value (a whole number greater than zero; default 1000; overrides APERTURE_ENUMERATE_LIMIT) (env: APERTURE_ENUMERATE_LIMIT) |
--seed | — | string | — | path to a JSON/YAML seed model to apply on startup (when omitted: the embedded example for the in-memory store, and nothing at all for a --store DSN) |
--store | — | string | — | DSN for the backing store: a postgres:// or postgresql:// URL for PostgreSQL, any other value as a SQLite path (defaults to in-memory). Set APERTURE_POSTGRES_SCHEMA to place Aperture's tables in a named PostgreSQL schema; unset uses the connection's search_path |
aperture export
Export the whole model to a single JSON/YAML state file (system-admin tier)
aperture export [options]
| Name | Aliases | Type | Default | Usage |
|---|---|---|---|---|
--account | — | string | — | active account (required for system-tier authority resolution) |
--format | — | string | — | output format: json (default) or yaml |
--out | — | string | — | write the state file to this path (default: stdout) |
--principal | — | string | — | authenticated principal performing the mutation (env: APERTURE_PRINCIPAL) |
--seed | — | string | — | path to a JSON/YAML seed model to apply on startup (when omitted: the embedded example for the in-memory store, and nothing at all for a --store DSN) |
--store | — | string | — | DSN for the backing store: a postgres:// or postgresql:// URL for PostgreSQL, any other value as a SQLite path (defaults to in-memory). Set APERTURE_POSTGRES_SCHEMA to place Aperture's tables in a named PostgreSQL schema; unset uses the connection's search_path |
aperture get
Read one entity by id (object-type|permission|principal|role|group|account|grant)
aperture get [options] <kind> <id>
| Name | Aliases | Type | Default | Usage |
|---|---|---|---|---|
--seed | — | string | — | path to a JSON/YAML seed model to apply on startup (when omitted: the embedded example for the in-memory store, and nothing at all for a --store DSN) |
--store | — | string | — | DSN for the backing store: a postgres:// or postgresql:// URL for PostgreSQL, any other value as a SQLite path (defaults to in-memory). Set APERTURE_POSTGRES_SCHEMA to place Aperture's tables in a named PostgreSQL schema; unset uses the connection's search_path |
aperture identifiers
List all valid instance ids of an object type from its provider
aperture identifiers [options] <object_type>
| Name | Aliases | Type | Default | Usage |
|---|---|---|---|---|
--enumerate-limit | — | string | — | maximum number of object ids one enumeration returns, and the ceiling a larger request limit is clamped down to. It configures the PROCESS, not the command: serve and every one-shot decision command honour the same value (a whole number greater than zero; default 1000; overrides APERTURE_ENUMERATE_LIMIT) (env: APERTURE_ENUMERATE_LIMIT) |
--exclude | — | string | — | id to omit from the result (repeatable); expands an exclusive allowance |
--seed | — | string | — | path to a JSON/YAML seed model to apply on startup (when omitted: the embedded example for the in-memory store, and nothing at all for a --store DSN) |
--store | — | string | — | DSN for the backing store: a postgres:// or postgresql:// URL for PostgreSQL, any other value as a SQLite path (defaults to in-memory). Set APERTURE_POSTGRES_SCHEMA to place Aperture's tables in a named PostgreSQL schema; unset uses the connection's search_path |
aperture impersonate
Start a time-boxed impersonation session (prints the session)
aperture impersonate [options]
| Name | Aliases | Type | Default | Usage |
|---|---|---|---|---|
--account | — | string | — | active account (required) |
--mode | — | string | "augment" | augment|become |
--operator | — | string | — | operator principal (env: APERTURE_PRINCIPAL) (required) |
--seed | — | string | — | path to a JSON/YAML seed model to apply on startup (when omitted: the embedded example for the in-memory store, and nothing at all for a --store DSN) |
--store | — | string | — | DSN for the backing store: a postgres:// or postgresql:// URL for PostgreSQL, any other value as a SQLite path (defaults to in-memory). Set APERTURE_POSTGRES_SCHEMA to place Aperture's tables in a named PostgreSQL schema; unset uses the connection's search_path |
--target | — | string | — | target principal to impersonate (required) |
aperture import
Apply a JSON/YAML state file as an idempotent transactional upsert (system-admin tier)
aperture import [options]
| Name | Aliases | Type | Default | Usage |
|---|---|---|---|---|
--account | — | string | — | active account (required for system-tier authority resolution) |
--file | — | string | — | path to the JSON/YAML state file (default: stdin, treated as JSON) |
--principal | — | string | — | authenticated principal performing the mutation (env: APERTURE_PRINCIPAL) |
--seed | — | string | — | path to a JSON/YAML seed model to apply on startup (when omitted: the embedded example for the in-memory store, and nothing at all for a --store DSN) |
--store | — | string | — | DSN for the backing store: a postgres:// or postgresql:// URL for PostgreSQL, any other value as a SQLite path (defaults to in-memory). Set APERTURE_POSTGRES_SCHEMA to place Aperture's tables in a named PostgreSQL schema; unset uses the connection's search_path |
aperture list
List entities of a kind (object-types|permissions|principals|roles|groups|accounts|grants)
aperture list [options] <kind>
| Name | Aliases | Type | Default | Usage |
|---|---|---|---|---|
--account | — | string | — | account to list grants for (required for kind=grant) |
--seed | — | string | — | path to a JSON/YAML seed model to apply on startup (when omitted: the embedded example for the in-memory store, and nothing at all for a --store DSN) |
--store | — | string | — | DSN for the backing store: a postgres:// or postgresql:// URL for PostgreSQL, any other value as a SQLite path (defaults to in-memory). Set APERTURE_POSTGRES_SCHEMA to place Aperture's tables in a named PostgreSQL schema; unset uses the connection's search_path |
aperture mcp
Serve the read-only Aperture MCP surface over stdio
Exposes Aperture's decision API (check/enumerate/explain, single + bulk), a read-only what-if simulator, and model inspection as MCP tools over stdio. No tool mutates. Intended to be spawned over stdio by an MCP client.
aperture mcp [options]
| Name | Aliases | Type | Default | Usage |
|---|---|---|---|---|
--enumerate-limit | — | string | — | maximum number of object ids one enumeration returns, and the ceiling a larger request limit is clamped down to. It configures the PROCESS, not the command: serve and every one-shot decision command honour the same value (a whole number greater than zero; default 1000; overrides APERTURE_ENUMERATE_LIMIT) (env: APERTURE_ENUMERATE_LIMIT) |
--seed | — | string | — | path to a JSON/YAML seed model to apply on startup (when omitted: the embedded example for the in-memory store, and nothing at all for a --store DSN) |
--store | — | string | — | DSN for the backing store: a postgres:// or postgresql:// URL for PostgreSQL, any other value as a SQLite path (defaults to in-memory). Set APERTURE_POSTGRES_SCHEMA to place Aperture's tables in a named PostgreSQL schema; unset uses the connection's search_path |
aperture put
Create or update an entity (object-type|permission|principal|role|group|account|membership|grant)
aperture put [options] <kind>
| Name | Aliases | Type | Default | Usage |
|---|---|---|---|---|
--account | — | string | — | active account (required for system-tier authority resolution) |
--file | — | string | — | path to a JSON entity body |
--json | — | string | — | entity body as inline JSON |
--principal | — | string | — | authenticated principal performing the mutation (env: APERTURE_PRINCIPAL) |
--seed | — | string | — | path to a JSON/YAML seed model to apply on startup (when omitted: the embedded example for the in-memory store, and nothing at all for a --store DSN) |
--store | — | string | — | DSN for the backing store: a postgres:// or postgresql:// URL for PostgreSQL, any other value as a SQLite path (defaults to in-memory). Set APERTURE_POSTGRES_SCHEMA to place Aperture's tables in a named PostgreSQL schema; unset uses the connection's search_path |
aperture revoke
Revoke a grant you previously bestowed
aperture revoke [options]
| Name | Aliases | Type | Default | Usage |
|---|---|---|---|---|
--delegator | — | string | — | principal revoking the grant (env: APERTURE_PRINCIPAL) (required) |
--grant | — | string | — | id of the grant to revoke (required) |
--seed | — | string | — | path to a JSON/YAML seed model to apply on startup (when omitted: the embedded example for the in-memory store, and nothing at all for a --store DSN) |
--store | — | string | — | DSN for the backing store: a postgres:// or postgresql:// URL for PostgreSQL, any other value as a SQLite path (defaults to in-memory). Set APERTURE_POSTGRES_SCHEMA to place Aperture's tables in a named PostgreSQL schema; unset uses the connection's search_path |
aperture search
Rank the objects a principal may act on by a free-text name
Ranks every object id under <pattern> that <principal> may take <action> on AND whose metadata matches <query>, best match first.
Every result is one aperture check would allow: candidates are DECIDED before they
are scored, by the same walk aperture enumerate uses, so the result is always a
subset of what that command returns. A score ranks; it authorizes nothing.
Matching is case- and punctuation-insensitive ("Nike, Inc." matches "nike inc") and tolerates a typo or a transposition on tokens long enough for one to be unambiguous. Only TEXT is matched — a string field and the string elements of a list field. Match a number, bool, or date with --field instead.
Aperture has no notion of a "label": a label is an ordinary metadata field whose name your host chose. By default every field holding text is searched; --in names the ones to search, and each result reports which field actually matched.
aperture search alice read 'account:acme/brand:*' nike
aperture search alice read 'account:acme/brand:*' nike --in label --limit 5
--field / --fields-json and --via mean exactly what they mean on enumerate, and they
COMPOSE with the query: the predicate and the reference edge narrow the candidate set,
the query ranks what is left. "The brand called Nike in dataset X" is one call.
--min-score drops weak matches (default 0.4). Raising it narrows the shortlist; it never widens what the principal may see.
--limit caps how many MATCHES come back — the top of a finished ranking, not a bound on the scan, so the best N are returned rather than the first N found. The scan itself runs to the deployment's --enumerate-limit ceiling.
aperture search [options] <principal> <action> <pattern> <query>
| Name | Aliases | Type | Default | Usage |
|---|---|---|---|---|
--account | — | string | "acme" | active account the search is scoped to |
--enumerate-limit | — | string | — | maximum number of object ids one enumeration returns, and the ceiling a larger request limit is clamped down to. It configures the PROCESS, not the command: serve and every one-shot decision command honour the same value (a whole number greater than zero; default 1000; overrides APERTURE_ENUMERATE_LIMIT) (env: APERTURE_ENUMERATE_LIMIT) |
--field | — | string | — | object-metadata predicate as key=value, repeatable; the value is ALWAYS a string, so --field seats=5 matches the string "5" and never the number 5 (use --fields-json for that). Overrides --fields-json on a key collision |
--fields-json | — | string | — | object-metadata predicates as a JSON object, for values that are genuinely a number, bool, or list (e.g. '{"seats":5,"active":true,"tags":["a"]}'). Merged first; --field entries then override by key |
--in | — | string | — | restrict matching to this metadata field; repeatable (default: every field holding text) |
--limit | — | int | 0 | cap the number of returned MATCHES for THIS request, clamped down to the deployment's --enumerate-limit ceiling (<=0 means that ceiling, which is 1000 unless configured) |
--min-score | — | float | 0 | drop matches scoring below this, 0 to 1 (<=0 means the default, 0.4) |
--scores | — | bool | — | print the score and the matching field/value alongside each id |
--seed | — | string | — | path to a JSON/YAML seed model to apply on startup (when omitted: the embedded example for the in-memory store, and nothing at all for a --store DSN) |
--store | — | string | — | DSN for the backing store: a postgres:// or postgresql:// URL for PostgreSQL, any other value as a SQLite path (defaults to in-memory). Set APERTURE_POSTGRES_SCHEMA to place Aperture's tables in a named PostgreSQL schema; unset uses the connection's search_path |
--via | — | string | — | restrict the result to the objects a holder's declared reference field names, as <holder-identity>.<field> (e.g. --via account:acme/dataset:x.current_brands); repeatable, and several edges are ANDed. The FIELD is everything after the LAST '.' |
aperture serve
Run the Aperture HTTP server
aperture serve [options]
| Name | Aliases | Type | Default | Usage |
|---|---|---|---|---|
--addr | — | string | ":8080" | TCP address to listen on |
--auth | — | string | — | authenticator adapter: dev|oidc|parsec (overrides APERTURE_AUTH_MODE; defaults to dev — bearer is the principal id, no external IdP) (env: APERTURE_AUTH_MODE) |
--enforce-membership | — | bool | — | deny any decision whose principal is not a member of the active account, before grants are consulted (defence-in-depth; lets shared roles be reused across accounts safely) (env: APERTURE_ENFORCE_MEMBERSHIP) |
--enumerate-limit | — | string | — | maximum number of object ids one enumeration returns, and the ceiling a larger request limit is clamped down to. It configures the PROCESS, not the command: serve and every one-shot decision command honour the same value (a whole number greater than zero; default 1000; overrides APERTURE_ENUMERATE_LIMIT) (env: APERTURE_ENUMERATE_LIMIT) |
--manage-accounts | — | bool | — | manage the lifecycle of account records — allow account create/update/delete through the API (default true; overrides APERTURE_MANAGE_ACCOUNTS). Pass --manage-accounts=false when accounts are mastered by an upstream system: Aperture then refuses every account write regardless of the caller's authority, while account reads and every decision stay unaffected. Read once at startup; a restart is required to change it |
--manage-memberships | — | bool | — | manage the lifecycle of principal-to-account memberships — allow membership create/update/delete through the API (default true; overrides APERTURE_MANAGE_MEMBERSHIPS). Independent of the other two, so a deployment can master accounts and principals upstream and still decide who belongs to what, or the reverse. Read once at startup; a restart is required to change it |
--manage-principals | — | bool | — | manage the lifecycle of principal records — allow principal create/update/delete through the API (default true; overrides APERTURE_MANAGE_PRINCIPALS). Pass --manage-principals=false when principals are mastered by an upstream directory or IdP: Aperture then refuses every principal write regardless of the caller's authority, while principal reads and every decision stay unaffected. Read once at startup; a restart is required to change it |
--seed | — | string | — | path to a JSON/YAML seed model to apply on startup (when omitted: the embedded example for the in-memory store, and nothing at all for a --store DSN) |
--store | — | string | — | DSN for the backing store: a postgres:// or postgresql:// URL for PostgreSQL, any other value as a SQLite path (defaults to in-memory). Set APERTURE_POSTGRES_SCHEMA to place Aperture's tables in a named PostgreSQL schema; unset uses the connection's search_path |
--wiring-poll | — | string | — | re-read the shared wiring tables on an interval instead of only at startup, so a wiring push from another host is noticed without a restart. A Go duration ("30s", "5m"), or on for the default of 30s, or off. Omitted means OFF — the instance is wired once, at boot, and starts no background reader (overrides APERTURE_WIRING_POLL) (env: APERTURE_WIRING_POLL) |
aperture template
Manage and apply provisioning templates
aperture template <command>
aperture template apply
Apply a template transactionally into --account (account-admin tier)
aperture template apply [options]
| Name | Aliases | Type | Default | Usage |
|---|---|---|---|---|
--account | — | string | — | active account (required for system-tier authority resolution) |
--id-prefix | — | string | — | prefix for generated grant ids |
--name | — | string | — | template name to apply (required) |
--param | — | string | — | parameter as name=value (repeatable) |
--principal | — | string | — | authenticated principal performing the mutation (env: APERTURE_PRINCIPAL) |
--seed | — | string | — | path to a JSON/YAML seed model to apply on startup (when omitted: the embedded example for the in-memory store, and nothing at all for a --store DSN) |
--store | — | string | — | DSN for the backing store: a postgres:// or postgresql:// URL for PostgreSQL, any other value as a SQLite path (defaults to in-memory). Set APERTURE_POSTGRES_SCHEMA to place Aperture's tables in a named PostgreSQL schema; unset uses the connection's search_path |
--version | — | int | 0 | template version (0 = latest) |
aperture template delete
Delete a template version, or all versions (system-admin tier)
aperture template delete [options] <name>
| Name | Aliases | Type | Default | Usage |
|---|---|---|---|---|
--account | — | string | — | active account (required for system-tier authority resolution) |
--principal | — | string | — | authenticated principal performing the mutation (env: APERTURE_PRINCIPAL) |
--seed | — | string | — | path to a JSON/YAML seed model to apply on startup (when omitted: the embedded example for the in-memory store, and nothing at all for a --store DSN) |
--store | — | string | — | DSN for the backing store: a postgres:// or postgresql:// URL for PostgreSQL, any other value as a SQLite path (defaults to in-memory). Set APERTURE_POSTGRES_SCHEMA to place Aperture's tables in a named PostgreSQL schema; unset uses the connection's search_path |
--version | — | int | 0 | template version to delete (0 = all versions of the name) |
aperture template get
Read a template by name (latest version unless --version)
aperture template get [options] <name>
| Name | Aliases | Type | Default | Usage |
|---|---|---|---|---|
--seed | — | string | — | path to a JSON/YAML seed model to apply on startup (when omitted: the embedded example for the in-memory store, and nothing at all for a --store DSN) |
--store | — | string | — | DSN for the backing store: a postgres:// or postgresql:// URL for PostgreSQL, any other value as a SQLite path (defaults to in-memory). Set APERTURE_POSTGRES_SCHEMA to place Aperture's tables in a named PostgreSQL schema; unset uses the connection's search_path |
--version | — | int | 0 | template version (0 = latest) |
aperture template list
List every template version
aperture template list [options]
| Name | Aliases | Type | Default | Usage |
|---|---|---|---|---|
--seed | — | string | — | path to a JSON/YAML seed model to apply on startup (when omitted: the embedded example for the in-memory store, and nothing at all for a --store DSN) |
--store | — | string | — | DSN for the backing store: a postgres:// or postgresql:// URL for PostgreSQL, any other value as a SQLite path (defaults to in-memory). Set APERTURE_POSTGRES_SCHEMA to place Aperture's tables in a named PostgreSQL schema; unset uses the connection's search_path |
aperture template put
Create or update a template (system-admin tier)
aperture template put [options]
| Name | Aliases | Type | Default | Usage |
|---|---|---|---|---|
--account | — | string | — | active account (required for system-tier authority resolution) |
--file | — | string | — | path to a JSON template body |
--json | — | string | — | template body as inline JSON |
--principal | — | string | — | authenticated principal performing the mutation (env: APERTURE_PRINCIPAL) |
--seed | — | string | — | path to a JSON/YAML seed model to apply on startup (when omitted: the embedded example for the in-memory store, and nothing at all for a --store DSN) |
--store | — | string | — | DSN for the backing store: a postgres:// or postgresql:// URL for PostgreSQL, any other value as a SQLite path (defaults to in-memory). Set APERTURE_POSTGRES_SCHEMA to place Aperture's tables in a named PostgreSQL schema; unset uses the connection's search_path |
aperture wiring
Manage the shared wiring a deployment keeps in its database
SHARED WIRING is the part of a seed document that belongs to the DEPLOYMENT
rather than to one instance: which object types are served and by what
statements (providers:), which metadata fields hold dates (field_types:),
the manifest of connection NAMES an entry may cite (connections:), and where
each attribute slot's bags come from (attribute_providers:).
Push it once and every instance sharing that database reads the same wiring — including an instance that has no seed file at all.
WIRING IS NOT MODEL STATE. The model says who exists and who may do what; wiring says where a decision reads object metadata and attribute bags FROM. The two are pushed by different commands, and wiring for a model that is not there is refused rather than stored.
WHAT IS NEVER STORED: no DSN, no credential, not even the NAME of the
environment variable holding one, and no filesystem path. Those are
per-instance facts — each instance resolves its own credentials and sizes its
own pool — so the manifest carries connection names and nothing else, and
kind: csv is refused because its only data source is a path. A csv entry
stays legal in the LOCAL seed file, where the path belongs to the instance
that reads it.
The two remaining sections, objects: and attributes:, are never shared:
they carry inline DATA rather than a pointer to data.
aperture wiring <command>
aperture wiring diff
Report how the deployed shared wiring differs from a seed document's four wiring sections, exiting non-zero on drift
Compares the wiring DEPLOYED in --store against the four shared wiring sections of
--seed — connections:, providers:, field_types: and attribute_providers: —
and names, per section and per entry, what is only deployed, what is only local,
and what differs. The section column is the document's own section key, so a
reported entry is one you can go and find.
IT IS MEANT FOR A PIPELINE. Identical wiring prints a clean report and exits 0; drift exits 2. Every coded refusal this binary makes exits 1, so the three outcomes stay apart: 0 is "the deployment matches the repository", 2 is "it does not", and 1 is "I could not tell" — an unreadable document, a --store that names nothing, a schema this build cannot read. A gate that failed closed on 1 and 2 alike would report drift for a typo in its own DSN.
DRIFT IS NOT AN ERROR and carries no error code. The two sides really do differ;
which of them is wrong is your call, and aperture wiring push --seed <file> --store <dsn> is how you make the deployment agree with the document.
FORMATTING IS NEVER DRIFT. Both sides are compared as WIRING, in the one
canonical order a read returns, so re-ordering the document's entries, re-ordering
a fields: or references: map, or re-indenting the file changes nothing in this
report. The push timestamps are excluded too: a stamp is a fact about the last
push and not about the wiring.
A kind: csv ENTRY IS REPORTED AS LOCAL-ONLY BY DESIGN, not as drift and not as
an error. Its only data source is a filesystem path, wiring push refuses it, and
so no push can ever make the deployment match it. Counting it as drift would leave
a permanently red gate on every deployment that legitimately keeps a local csv
provider, and a gate that cannot go green is a gate somebody switches off.
A STORE WITH NOTHING DEPLOYED IS AN ANSWER, not a refusal, and this is the one
place a diff disagrees with aperture wiring pull. A pull refuses an empty store,
because the document it would write is one that, pushed back, replaces the
deployment's wiring with nothing. A diff writes no file and pushes nothing, so it
reports every local entry as only-local and says plainly that nothing is deployed
— which is also what a mistyped --store looks like, so check the DSN before
concluding a push was lost.
No actor is required, and none is accepted, for the reason wiring show accepts
none: this restates wiring the --store credential already grants full write access
to. It contacts no provider, opens no host connection, and prints no account,
principal or object identity, because the shared wiring holds none.
aperture wiring diff [options]
| Name | Aliases | Type | Default | Usage |
|---|---|---|---|---|
--seed | — | string | — | path to the JSON/YAML seed document whose four SHARED wiring sections the deployment is compared against (required; nothing is applied, pushed or written, and there is no embedded-example fallback) |
--store | — | string | — | DSN for the shared store the wiring lives in: a postgres:// or postgresql:// URL for PostgreSQL, any other value as a SQLite path (required — there is nothing to share about an in-memory store). Set APERTURE_POSTGRES_SCHEMA to place Aperture's tables in a named PostgreSQL schema |
aperture wiring pull
Write the store's deployed shared wiring out as the four seed sections, for diffing against version control
Reads the deployed wiring in ONE atomic snapshot and writes it to --out as
connections:, providers:, field_types: and attribute_providers: — the same
four sections wiring push reads. The file is a seed document wiring push
accepts unchanged, so push then pull then push deploys the identical wiring,
and two pulls of an unchanged deployment are byte-identical.
WHAT IT IS FOR is answering "is what is deployed what is in the repository?" with
a diff. aperture wiring show answers "what is deployed?" in words and is the
better command for reading; this one produces a file for a tool.
THE FILE IS RE-PUSHABLE BUT NOT BOOTABLE, and the difference is the security rule
made visible. Shared wiring holds a connection's NAME and nothing else — no DSN,
no credential, not even the NAME of the environment variable holding one, and no
filesystem path — so every connection comes back with an empty dsn_env:. Fill
those in from your own deployment's environment before booting an instance from
the file; an instance built from it as written refuses at registry build and names
the unset variable. Nothing in the output is a secret, and the format has no
dsn: key to put one in.
NO MODEL STATE IS WRITTEN. aperture export emits the model and no wiring; this
emits the wiring and no model. The file carries no accounts, principals, objects
or inline data, because the shared wiring holds none — and it does not spell the
model sections out as empty either, since a populated deployment's model is not
empty and a document that said so is one somebody would import.
AN EXISTING --out FILE IS REFUSED unless --force is given. The likeliest thing at that path is the version-controlled document the pull is meant to be compared with, and overwriting it silently destroys the left-hand side of the comparison. The path is checked before the store is opened, so the refusal reads nothing.
A STORE WITH NO WIRING DEPLOYED IS REFUSED, which is the one place this command
disagrees with wiring show. show only describes an empty store, and nothing
deployed is a useful answer there. A pull produces a file whose purpose is to be
pushed back, and an empty one pushed back replaces the deployment's wiring with
nothing — while an empty read is also exactly what a mistyped --store naming a
database Setup just created looks like.
No actor is required, and none is accepted, for the reason show accepts none:
this restates wiring the --store credential already grants full write access to.
aperture wiring pull [options]
| Name | Aliases | Type | Default | Usage |
|---|---|---|---|---|
--force | — | bool | — | overwrite the --out file if it already exists |
--format | — | string | — | output format: json or yaml (default: inferred from the --out extension — .json is JSON, anything else is YAML) |
--out | — | string | — | write the wiring document to this path (required; an existing file is refused unless --force is given, and there is no stdout default because aperture wiring show is the command for reading) |
--store | — | string | — | DSN for the shared store the wiring lives in: a postgres:// or postgresql:// URL for PostgreSQL, any other value as a SQLite path (required — there is nothing to share about an in-memory store). Set APERTURE_POSTGRES_SCHEMA to place Aperture's tables in a named PostgreSQL schema |
aperture wiring push
Validate a seed document's four shared wiring sections and write them to the store in one transaction
Reads providers:, field_types:, connections: and attribute_providers: out
of --seed, validates every entry, and REPLACES the store's wiring with them in a
single transaction. The document's other sections are not read: no model state
is applied, and the two local wiring sections (objects:, attributes:) are
untouched.
THE PUSH IS ALL OR NOTHING. Every rule below is checked before anything is written, and the write itself replaces the whole set in one transaction, so a refusal leaves the deployed wiring exactly as it was. Wiring is only meaningful whole — an entry naming a connection the manifest does not list is not half-valid wiring, it is broken wiring — and an instance booting against a half-written set would build a registry missing exactly the entries whose write failed, while reporting nothing.
REPLACE, not merge. What is in the document is what the deployment will run; an entry dropped from the document is dropped from the store. Push the whole wiring every time.
A push is refused when:
* the store holds NO MODEL STATE at all — apply the model first, and check the
--store DSN, because a typo names an empty database Setup will create
* an entry selects `kind: csv` — its only data source is a filesystem path,
and a path is machine-local
* a provider serves an `object_type` the store has no row for (named in the
refusal)
* an entry names a `connection:` the pushed `connections:` manifest does not
declare (named in the refusal)
* anything carries a literal `dsn:` — only `dsn_env:`, a variable NAME, is ever
accepted, and shared wiring stores neither
* the document declares NO wiring at all and --allow-empty was not given
AN ALL-EMPTY DOCUMENT IS REFUSED, and it is the one refusal about what the push
would MEAN rather than about what is wrong with it. Because a push replaces, an
empty set retires every entry the deployment is running: all five tables are
emptied and every instance goes back to building its wiring from its own --seed
file — or, if it has none, from NOTHING, which still starts, with every attribute
slot unregistered. An unregistered slot answers with an empty bag, so a rule that
EXCLUDED on an attribute stops excluding and the grant WIDENS, with nothing in a
verdict, a trace or a note to say so. The likeliest causes are the wrong --seed
path and a mistyped section key (provider: for providers:), which is not an
error — it is a section that simply is not there. Pass --allow-empty to retire the
wiring deliberately.
No actor is required: the store credential is the authority, exactly as it is for
aperture import.
aperture wiring push [options]
| Name | Aliases | Type | Default | Usage |
|---|---|---|---|---|
--allow-empty | — | bool | — | push a document that declares NO wiring at all, which RETIRES every entry the deployment is running: all five tables are emptied and every instance goes back to building its wiring from its own --seed file, or from nothing if it has none. Refused without this flag, because the commonest cause is the wrong --seed path or a mistyped section key |
--seed | — | string | — | path to the JSON/YAML seed document whose four SHARED wiring sections are pushed (required; no model state is applied from it and there is no embedded-example fallback) |
--store | — | string | — | DSN for the shared store the wiring lives in: a postgres:// or postgresql:// URL for PostgreSQL, any other value as a SQLite path (required — there is nothing to share about an in-memory store). Set APERTURE_POSTGRES_SCHEMA to place Aperture's tables in a named PostgreSQL schema |
aperture wiring show
Print the shared wiring this store has deployed, one table per section
Reads the five shared-wiring tables and prints them: the connection manifest, the provider entries with their reference declarations, the field-type declarations, the attribute-provider entries, and the statement set every database-backed entry runs. It answers "what is this deployment actually wired to?" without a database client.
AN EMPTY STORE IS AN ANSWER, not an error. A store nothing has been pushed to says so plainly: every instance booting against it builds its wiring from its own --seed file instead. It is also what a mistyped --store looks like, because a DSN naming a database that does not exist yet is one Setup creates — so check the DSN before concluding a push was lost.
THE COLUMNS AN OPERATOR WOULD OTHERWISE HAVE TO GUESS AT are spelled as words
rather than left blank. ttl and max-size read (default) when the entry sets
neither, because the registry's own default applies and 0 would read as
"caches nothing". connection and id-column read - when the kind does not use
them. An attribute slot with no get_all is reported as FETCH-ONLY: every
decision path works unchanged and only the system-tier directory read refuses.
THE DECLARED KEY SET distinguishes three states, because two of them are
different answers a blank column would merge: (not declared) is a slot that
opted out of key enforcement entirely, (declared empty) is a slot that opted IN
and permits no keys at all, and a list is the keys the slot guarantees.
No actor is required, and none is accepted. This restates the wiring that the
--store credential you just supplied already grants full write access to, so
requiring an authority on top of it would only mean nobody could diagnose "is
anything even deployed?" without already holding the authority the diagnosis
explains — the same reason aperture attributes slots is ungated. It contacts no
provider, opens no host connection, and prints no account, principal or object
identity, because the shared wiring holds none.
aperture wiring show [options]
| Name | Aliases | Type | Default | Usage |
|---|---|---|---|---|
--store | — | string | — | DSN for the shared store the wiring lives in: a postgres:// or postgresql:// URL for PostgreSQL, any other value as a SQLite path (required — there is nothing to share about an in-memory store). Set APERTURE_POSTGRES_SCHEMA to place Aperture's tables in a named PostgreSQL schema |
Deployment
Audience: operators running Aperture as a long-lived service.
Aperture ships as a single pure-Go binary (CGO_ENABLED=0, no external runtime).
Running it as a service is one command — aperture serve — which hand-wires the
whole dependency graph (storage → engine → service → HTTP handler) and boots a
net/http server exposing the HTTP + Twirp API and the admin UI.
bin/aperture serve --addr :8080
aperture serving on :8080
serve listens on :8080 by default and shuts down gracefully on SIGINT /
SIGTERM, draining in-flight requests within a 10-second window before it
forces the listener closed.
Flags
| Flag | Default | Env source | Purpose |
|---|---|---|---|
--addr | :8080 | — | TCP address to listen on. |
--store | (in-memory) | — | SQLite DSN for the backing store. Empty ⇒ ephemeral in-memory store. |
--seed | (see below) | — | Path to a JSON/YAML seed model, applied in full on every boot. Empty ⇒ the embedded acme example fixture with no --store, and nothing at all with one. |
--auth | dev | APERTURE_AUTH_MODE | Authenticator adapter: dev, oidc, or parsec. The flag overrides the env var. |
--enforce-membership | off | APERTURE_ENFORCE_MEMBERSHIP | Deny any decision whose principal is not a member of the active account, before grants are consulted. |
--enumerate-limit | 1000 | APERTURE_ENUMERATE_LIMIT | Ceiling one enumeration is bounded by — the number a request with a non-positive limit receives, and the number a larger limit is clamped down to. Bounds the scope member gather too, so one value governs both. The flag overrides the env var; a value that is not a whole number greater than zero — banana, 0, -5 — fails the boot with APERTURE_CONFIG_INVALID naming the setting and the value. |
A durable --store with no --seed seeds nothing. serve runs Setup,
reads the model that is already in the database, and writes no model rows on
startup. That is what lets a deployment restart without overwriting itself, and
what lets two instances share one database. Passing a --seed alongside a
durable --store still applies that whole document on every boot — accounts,
principals, roles, groups, grants, rules — so it is a provisioning step, and two
instances pointed at the same database must not both carry it.
--enumerate-limit is the one row in that table that is not a serve flag.
It configures the deployment, so check, enumerate, identifiers, explain
and mcp carry the same flag and read the same variable, and every one of them
resolves it to the same number the server does. Set it once in the process
environment and the whole binary agrees; set it on serve alone and the
container's own aperture enumerate would answer a different question from the
service it is inspecting.
The generated, always-current flag table is the Command-Line Reference.
The backing store (--store)
--store selects the storage backend:
-
Empty (the default) → the pure-Go in-memory backend. Ideal for demos, CI, and read-only trials; nothing is persisted across restarts.
-
A DSN → the SQLite backend (
modernc.org/sqlite, pure-Go, so CGO stays off). The DSN is amodernc.org/sqlitedata source name — a file path or afile:URL with pragmas, for example:bin/aperture serve --store 'file:aperture.db?_pragma=busy_timeout(5000)'
The SQLite pool is capped at a single connection so writes serialize cleanly
under SQLite's single-writer model. On startup the server runs the embedded
schema (Setup) and then loads the model from --seed (or the embedded
example) into the store.
Configuration precedence
Aperture is configured, in order of increasing precedence:
.envfile — when you launch throughmake, a.envin the working directory is loaded and its keys are exported into the environment before the binary runs (include .env; exportin theMakefile). This is the dotenv convenience; the binary itself simply reads its process environment.APERTURE_*environment variables — the primary configuration surface. The authenticator is built from these viaauth.ConfigFromEnv(see below).- Command-line flags — a flag that declares an env source overrides that
env var. For example
--auth oidcwins overAPERTURE_AUTH_MODE=dev.
The seed model itself is authored as YAML (or JSON) and supplied with
--seed; that document also carries the connections: and providers: wiring
the server turns into a live object-provider registry. Model YAML is data, not
process config — the two are separate.
A seed file is no longer the only home for that wiring. Four of its six wiring
sections — connections: (names only), providers:, field_types: and
attribute_providers: — can be pushed into the store every instance of a deployment
shares, with aperture wiring; where those rows exist the
database is authoritative and a local file may only add to it. That is how an
instance with no --seed at all is wired. See
Two instances, one store.
One deployment consequence of that split: a kind: sql provider names its
database through dsn_env:, and a literal dsn: in a seed file is refused at
parse. So each declared connection needs its environment variable exported
where the process runs — the seed file names the variable, the environment holds
the credential. Nothing is dialled at startup (sql.Open is lazy and Aperture
does not ping), so a wrong host or password surfaces on the first decision
touching a SQL-backed object-type as APERTURE_SQL_PROVIDER_QUERY, not as a
failed boot. See
Database-backed providers.
Authentication environment variables
The authenticator adapter maps each request to an Aperture principal. The
default is dev (the bearer token is the principal id), so serve runs with
no external identity provider out of the box; oidc and parsec are opt-in.
| Variable | Applies to | Meaning |
|---|---|---|
APERTURE_AUTH_MODE | all | Adapter: dev | oidc | parsec. Empty ⇒ dev. |
APERTURE_AUTH_PRINCIPAL_CLAIM | oidc, parsec | Verified claim mapped to the principal id (sub, email, …). Empty ⇒ sub. The dev adapter ignores it. |
APERTURE_OIDC_ISSUER | oidc | OIDC issuer URL. |
APERTURE_OIDC_AUDIENCE | oidc | Expected token audience. |
APERTURE_OIDC_JWKS_URL | oidc | JWKS endpoint for signature verification. |
APERTURE_PARSEC_KEYRING | parsec | Path to the broker's persisted signing keyring (keyring.json) Aperture verifies brokered tokens against. |
APERTURE_PARSEC_STATE_DIR | parsec | Parsec broker state directory. |
An unrecognised APERTURE_AUTH_MODE fails the boot with APERTURE_CONFIG_INVALID.
The oidc adapter performs network discovery at startup, so misconfiguration
there surfaces immediately when serve boots.
The enforce-membership toggle (--enforce-membership /
APERTURE_ENFORCE_MEMBERSHIP) is defence-in-depth: a non-member of the active
account is denied before any grant is read, which is what lets a single shared
role (manager, analyst, …) be reused across customer accounts without one
account's grants leaking to another's members.
Manual dependency injection
serve builds its graph with plain constructors — no DI framework (no
wire/fx/dig). Each layer is a hand-written call:
buildStore(--store, --seed) # storage backend + schema + seed
→ engine.New(store, …) # decision engine (+ scope resolution, membership)
→ service.New(eng, …) # the fully-wired facade
→ server.New(svc) # HTTP + Twirp handlers + admin UI
→ server.Authenticate(authn, …) # request → principal middleware
The same fully-wired facade the mutation CLI commands build is what serve puts
behind a listener — the engine for decisions, the admin gate for tier checks, the
delegation and impersonation services, the append-only audit trail (decisions
sampled at 100 % under serve so the demo trail is legible), the rules engine
over a storage-backed rule source, and the seed's declared object providers. A
rule saved through the admin UI takes effect on the next decision with no
separate rule store.
Because the wiring is explicit Go, there is no configuration container to learn:
the constructor order in internal/cli/serve.go is the deployment topology.
Related
- serve — the command page, with the flag walkthrough.
- Two instances, one store — standing up a second instance against a database that already has one: one model, one pushed wiring, per-instance connection routes.
- Refreshing wiring on a live fleet — what a
wiring pushdoes to instances that are already running, what needs a restart anyway, and how a stale instance surfaces. - Performance & NFR — the decision hot-path budget and how to assert it.
- Troubleshooting — reading and acting on
APERTURE_*boot/runtime errors. - Command-Line Reference — the generated flag table.
Two instances, one store
Audience: operators standing up a second Aperture instance against a database that already has one.
This is the topology shared wiring exists for. Two processes — a host application that owns the model and a second service that only asks questions — share one store, hold one copy of the wiring, and must answer every question identically. Getting there is four steps, and the order matters.
Throughout, the deployment is the house example: one account, acme, whose
objects are spelled account:acme/project:atlas/document:42.
The shape
| Instance A — the host | Instance B — the consumer | |
|---|---|---|
| Command | aperture serve --store <dsn> --seed model.yaml | aperture serve --store <dsn> |
| Model state | applies it (its file is the source) | reads it (writes nothing on boot) |
| Shared wiring | authored it, pushed it | reads it out of the database |
| Local sections | objects:, attributes: from its own file | its own, or none at all |
| Connection routes | its own connections: block, or the environment | the environment |
Instance B is a pure consumer. There is nothing to build for it and nothing to
configure beyond its store DSN and its connection routes: aperture serve --store <dsn> with no --seed boots, reads the model that is already there, reads the
wiring that is already there, and serves.
Both instances hold the whole decision engine. There is no "primary" — a decision is resolved locally in each process against the shared store, so the pair scales reads by adding processes and the two never talk to each other.
1. Apply the model once
Model state — accounts, principals, roles, groups, permissions, grants, rules, object types — is applied by whoever owns the document, on one instance, and nowhere else.
aperture serve --store 'postgres://…/acme' --seed model.yaml
seed.Document.Apply upserts the entire document, so two instances that both
carried a --seed would re-assert their own model over each other on every
restart, and the last one to boot would win. That is why a durable --store
with no --seed seeds nothing — see
What an omitted --seed means. Instance B's boot
writes no model rows at all.
2. Push the wiring once
Runtime wiring is not model state: Apply never writes it, and an export never
reproduces it. Four of its sections are nevertheless the same on every instance of
a deployment, so they get a home in the store:
aperture wiring push --store 'postgres://…/acme' --seed model.yaml
That writes connections: (names only), providers:, field_types: and
attribute_providers:. Read them back with aperture wiring show, snapshot them
to a file with aperture wiring pull, and compare a repository document against
what is deployed with aperture wiring diff. The flags and descriptions of the
whole command tree are in the generated
Command-Line Reference.
Push once, from one document. With wiring rows present, the database is authoritative on every instance, including the one the push came from.
3. Give each instance its own connection routes
A shared connection carries its name and nothing else. No DSN, no credential,
not even the dsn_env: variable name — because which server, which credential and
how large a pool are per-instance facts, and a wiring row is copied to a second
host that resolves its own credentials.
So each instance routes each name itself, in one of three ways:
- A Go host passes
seed.WithConnectionOpenerand builds the pool for the name itself. The documented seam, and the only one a library host needs. - This instance's own seed file declares a
connections:entry under the same name. It is used verbatim —dsn_env:, pool sizes,query_timeout:— because a route is exactly what a localconnections:entry is. - The conventional environment variable
APERTURE_CONNECTION_<NAME>_DSN. Every character that is not a letter or digit becomes an underscore, so the connection namedmainreadsAPERTURE_CONNECTION_MAIN_DSN.
For instance B, which has no seed file, route 3 is the whole configuration:
export APERTURE_CONNECTION_MAIN_DSN='postgres://reader:…@replica/acme?sslmode=require'
aperture serve --store 'postgres://…/acme'
A name no route answers for refuses the boot with
APERTURE_WIRING_CONNECTION_UNROUTED, naming the connection and the variable it
wanted — every unrouted name in one refusal, so a new instance is configured in one
pass rather than one restart per connection.
4. Keep the two local sections local
Two sections of a seed document are never pushed, because they carry data rather than a pointer to data:
objects:— inline object metadata.account:acme/project:atlasand itsname,tier,reviewed_at.attributes:— inline subject attribute bags. Whatprincipal.departmentandaccount.planread.
They belong to the instance whose file lists them, and each instance keeps its own.
An instance that needs the metadata a rule reads must therefore either list it
inline, or reach it through a providers: entry — which is shared, and which
is the answer for anything two instances both need.
The same applies to any wiring only one machine can describe. A local file may
add an object type or an attribute slot the database never declared — a
kind: csv provider, whose data source is a filesystem path the shared tables have
no column for, or a provider a Go host registers itself — and it is built exactly as
it would be on an instance that has never been pushed to. What it may not do is
redeclare, in a wiring section, something the database already declares: that
fails the boot with APERTURE_WIRING_LOCAL_COLLISION, naming the entry and both
sections. The collision is refused rather than resolved because either precedence is
silent and either one changes what a decision reads. An inline attributes: block
for a slot the database declares is not that case: it is the slot's local layer
under the pushed row, the shared layer wins every key both serve, and nothing is
dropped. See
With both, the database wins and the file may only ADD.
An instance missing a declared provider refuses to boot
This is the part to read before deciding an instance is "mostly configured".
If the shared wiring declares something this instance cannot turn into a working
provider — a kind: no second host can construct from a row, a connection name it
has no route for, an incomplete statement set, an unparseable ttl:, a
references: target nothing serves, a field_types: word outside
date/datetime — the process exits non-zero at startup. It does not skip the
entry, it does not serve the types it happened to manage, and it does not wait to
find out on the first decision that needed the data.
Refusing is not the tidier option; it is the only one that is not silent.
- An object type with no working provider does not error on a decision. A rule
reading
object.tierreads a missing path, every predicate over it is false, and the grant denies. Nothing in the verdict says a provider was missing. - An attribute slot with no working provider is worse. The leniency contract collapses a provider failure to an empty bag, and an empty bag does not deny — a rule that excluded on an attribute it can no longer see stops excluding, so the grant widens. Nothing in the verdict, the trace or the notes says so.
- Failing per-decision instead would be the worst of the three: a misconfigured instance would keep serving traffic for the types it could reach, so the pair would answer one question two ways, and the only observable symptom would be a verdict that differs by which instance the request landed on.
A boot that fails is a deployment that fails, in front of whoever is deploying it, with a coded error naming the entry and a fixup naming the remedy. A boot that degrades is an authorization difference nobody is looking for. That is the trade, and it is not configurable.
Verifying the pair agrees
The claim that the two instances decide identically is a test, not a hope:
internal/cli/wiring_identical_test.go builds two decision stacks over one store —
one wired from the database rows, one from the equivalent seed file — and diffs
Check, Enumerate, Search and Explain across fixtures that read object
metadata, both attribute roots and a shared field_types: declaration. It runs
under make test against the in-memory and SQLite backends, and under the gated
live run against a real PostgreSQL server (see
Build, test & lint gates).
Operationally, four questions are worth asking of a pair, and each has one answer:
| Question | Answer |
|---|---|
| What wiring is deployed? | aperture wiring show --store <dsn> |
| Does it match the document in the repository? | aperture wiring diff --store <dsn> --seed model.yaml |
| Which attribute slots did this instance actually wire, and from where? | aperture attributes slots --store <dsn> --seed <file> — the source column says (shared wiring) for a pushed row and (--seed file) for a local one |
| Are the two really running the same configuration? | The WiringPosture read on each instance: both digest and local_digest equal |
Run the last two on each instance. They restate what that process resolved, which is the one thing a shared store cannot tell you.
The digest pair is the sweep to build a fleet check on, and it takes both
fields. digest is the shared wiring's — equal digests mean the same push has
landed — and local_digest is the digest of that instance's own local document,
which a rebuild re-reads and which carries the two file-local sections (objects:
metadata and attributes: bags). Two instances can report the identical digest
and still decide differently if their local documents differ; agreement on both is
what says they were built from the same configuration. local_digest is empty for
an instance wired only from the shared tables, which is the normal state for the
second instance of this topology. See Refreshing wiring on a live
fleet for what each field does after a push.
After the pair is up
Standing the pair up is the first push. What the next one does to instances that are already serving — which of them notices, how long the two are allowed to disagree, what still needs a restart, and how an instance that stopped keeping up surfaces — is Refreshing wiring on a live fleet.
Refreshing wiring on a live fleet
Two instances, one store is about standing a fleet up.
This page is about what happens to it afterwards, when somebody runs
aperture wiring push against a store those instances are
already serving from.
The flag-level account — the value vocabulary, what a tick costs, what a swap replaces — is on the command page: serve, "Noticing a push without a restart". This page does not repeat it. It answers the three questions an operator has about a fleet rather than about a flag: what a push does to instances that are already running, what it cannot do without a restart, and how you find out that an instance stopped keeping up.
The default is boot-only, and a restart is a real answer
An instance reads the shared wiring once, at startup. Unless you set
--wiring-poll / APERTURE_WIRING_POLL, that is the whole behaviour: no background
reader, no periodic query, nothing on a timer. A push is picked up by restarting the
instances.
That is not a degraded mode. If your deployment already rolls instances on a config change, the pipeline you have is the refresh mechanism, and turning polling on adds a second one. Polling earns its place when a restart is expensive or not yours to order — an Aperture embedded in a host process, where restarting Aperture means restarting the host, is the case it was built for.
# Boot-only: the default. Nothing to configure.
aperture serve --store "$DSN"
# Opt in, at the default 30s:
aperture serve --store "$DSN" --wiring-poll on
# Opt in, at an interval you chose:
APERTURE_WIRING_POLL=2m aperture serve --store "$DSN"
A value the vocabulary does not accept fails the command with
APERTURE_CONFIG_INVALID before any connection is opened, so a typo in a
deployment manifest costs you a refused start and not a half-configured instance.
The interval is a staleness budget, not a tuning knob
The interval is the window in which your fleet is allowed to disagree with
itself: from the moment of the push until a given instance re-reads and adopts,
that instance is still enforcing the wiring it booted on. It is the same kind of
number as an attribute slot's ttl: — a bound on how long something already
replaced keeps being honoured — and it belongs in the same conversation as your
other revocation windows.
It is not a performance number. A tick is one GetWiring and one digest
comparison, and the measurement is in What it costs below: driven
at 1 ms, thirty thousand times the default rate, it costs a few percent of
single-goroutine throughput and nothing the p99 can distinguish from noise. Choose
the interval by how long you are willing for two instances to answer differently, and
by nothing else.
What a push does to a running fleet
Each instance notices independently, on its own tick, and adopts on its own. There is no coordination, no leader and no barrier — so for up to one interval after a push, some instances are answering from the new wiring and some from the old.
That is the honest shape of the feature and it is worth planning for: a push is eventually consistent across a fleet, with the interval as the bound. If a change must be simultaneous everywhere, a push plus a coordinated restart is the mechanism, not polling.
Within a single instance, however, nothing is partial:
| Property | What it means for you |
|---|---|
| A version is installed whole or not at all | A rebuild that fails installs nothing. There is no instance running "half of" a push. |
| A request pins one version at entry | A push landing mid-request cannot change what that request decides. Requests in flight finish on the wiring they started with. |
| A decision never waits for a wiring read | The rebuild happens on the poll's own goroutine. A request pays one atomic pointer load. |
| The listener and the authenticator survive | Connections are not dropped and nobody is re-authenticated. Only what sits under the HTTP layer is replaced. |
Each instance reports what it did, on stderr:
wiring poll: the deployed wiring CHANGED (3f9a1c72 -> 8b40e5de) and this instance
ADOPTED it; decisions already in flight finish on the wiring they started with
The two short hashes are digests of the wiring itself, and they are the handle for "did this instance get the push?" across a fleet: after everyone has adopted, every instance names the same new digest. They contain no account, principal or object data.
One thing a sweep on those digests does not tell you, and one thing that needs the second digest:
-
A rebuild also re-reads that instance's local
--seedfile. The two local sections — inlineobjects:metadata and inlineattributes:bags, plus the declared attribute-key sets taken from them — are read from disk again on every swap. So a file rewritten in place since the instance started (a ConfigMap volume remount, a redeploy that only updates a mounted file) takes effect on the next swap, without a restart, triggered by whatever shared push happened to come along. -
Equal shared digests are not by themselves proof that two instances decide the same — the shared digest covers the five tables only, deliberately, so that it stays comparable with what a push wrote and with what
aperture wiring diffreports. The local half is reported as its own field,WiringPosture.LocalDigest: the digest of the document the running version was built from, empty when the instance has none. Sweep both —You compare You learn Digestthe same push has landed on both instances DigestandLocalDigestthe two were built from the same configuration at all LocalDigestmoves with an adoption exactly asDigestdoes, and the two advance together or not at all, so a posture never reports a pair no version was built from. It is a content digest: a regenerated-but-equivalent file digests the same, so a deploy pipeline that reformats the document does not read as drift.
What a push cannot change without a restart
The set of connection names. The shared tables carry a connection's name and
nothing else, because which server, which credential and how big a pool are facts
each instance resolves for itself at boot — from seed.WithConnectionOpener in a Go
host, from a connections: entry in that instance's own --seed file, or from
APERTURE_CONNECTION_<NAME>_DSN. A running process can neither invent a route for a
name that appeared while it was working nor drain a pool for a name that vanished.
So the name set is frozen for the life of the process, in both directions, and a
push that changes it is reported and not applied, with
APERTURE_WIRING_RESTART_REQUIRED naming the added and dropped names. The
consequence that matters operationally: the rest of that push is held too. The
providers, field types and attribute providers pushed alongside a connection change
stay outstanding with it, because a push is adopted whole or not at all.
That makes the order of operations for a connection change a three-step rollout, and the first step is the one people skip:
- Give every instance its route first, while the old wiring is still deployed.
Export
APERTURE_CONNECTION_<NAME>_DSN, or add theconnections:entry to that instance's local file. Nothing has changed yet, so nothing can break yet. - Push. Every polling instance now reports
APERTURE_WIRING_RESTART_REQUIREDon each tick, and every one of them keeps deciding through the wiring it has. - Restart the instances, in whatever order your rollout allows. Each one re-reads from scratch, resolves the new name against the route you supplied in step 1, and adopts the whole push.
Skip step 1 and step 3 fails instance by instance: a boot that cannot route a
declared connection refuses to start with
APERTURE_WIRING_CONNECTION_UNROUTED. That is the deliberate difference between the
two codes — a boot refuses, and a running process keeps deciding.
When a refresh fails
No refresh failure stops an instance deciding. An unreachable store, wiring this host cannot build, a rebuild that fails, a frozen name set: all of them keep the wiring already in memory and the instance goes on answering. That is not leniency — an access engine embedded in a host process takes the host down with it if it stops answering, and a fleet that stops deciding because one operator pushed one bad row is a worse outcome than a fleet deciding from wiring one push behind.
The price is staleness, and the instance is loud about it. It keeps re-detecting the change on every tick — the digest does not advance until an adoption succeeds — so the condition repeats rather than scrolling past once:
wiring poll: re-reading the shared wiring failed, so this instance keeps the wiring
it has: [APERTURE_STORAGE_SCHEMA_INCOMPATIBLE] ...
Reading the posture without reading logs
Every failure is also recorded, and the record is a read on the facade:
WiringPosture, over Twirp or in Go, requiring system-admin authority. It
reports whether this instance polls at all, the interval, the digest it is deciding
from, whether the last refresh failed, the APERTURE_* code and message it failed
with, the consecutive failure count, and — the field you escalate on — how long
the current run of failures has lasted.
There is deliberately no metrics subsystem in Aperture, and nothing here emits
one. The two channels are this read and the poller's stderr lines; a fleet-wide
health sweep is a WiringPosture call per instance.
Four things about the read are worth knowing before you build a sweep on it:
- It is authenticated on purpose, where
Capabilitiesis open. "This instance has been enforcing configuration its operator already replaced, for four hours" is operational intelligence about a fault, and not the kind of deployment factCapabilitiespublishes anonymously. The full reasoning lives inservice/wiring_posture.go. - An instance that does not poll answers the zero posture — not polling, not stale — rather than refusing. That is what makes one sweep work across a fleet where some instances have opted in and some have not: a boot-only instance is not stale, it is running the wiring it was told to run.
- The duration keeps growing while the loop is broken, because it is computed when you read it and not accumulated on each tick. An age that only advanced when the failing thing managed to run would under-report exactly the failure that stopped the loop dead. It keeps growing across a refused push too: the window runs from the first refusal to now, however many ticks have read the tables fine in between.
- A refused push reports as stale, not healthy. This is the case that is easy to assume the other way: the posture is not only about a store the instance could not reach. An instance that read the tables perfectly well and then refused to adopt what they said — a frozen connection name set, a provider kind it cannot build — is knowingly running superseded wiring, which is a worse posture than not having looked, and it says so.
Stopping an instance mid-refresh
A SIGTERM stops the reader immediately. A refresh that is already rebuilding is
waited for, for five seconds, and then abandoned with a line saying so — because a
rebuild re-reads the local seed file and opens every declared CSV, and none of that
is interruptible. Sizing your termination grace against the ten seconds of graceful
shutdown is therefore still right, with five seconds of headroom rather than an
unbounded wait. Nothing is lost: a wiring version is installed whole or not at all,
so an abandoned rebuild installed nothing, and the replacement instance reads the
wiring from scratch.
A shutdown is not a failure
SIGTERM landing while a tick is mid-read — or mid-rebuild — cancels it. That is
not recorded as staleness, and it is deliberate: graceful shutdown then drains
for up to ten seconds, and an instance that reported Stale: true with reason
context canceled for that window would show up as degraded in every sweep taken
across an ordinary rolling restart. The line is still written, so an abandoned
refresh is legible:
wiring poll: re-reading the shared wiring was abandoned because this process is
shutting down; nothing is stale and no alarm is recorded
A store that genuinely went away at the same moment as the process is a different thing and is alarmed, with its own code. The distinction is why the step ended, not whether the process is stopping.
Recovery needs nothing from you
The next completed refresh clears the alarm, resets the duration and the failure count, and adopts normally. A refresh that observes no change counts as completed too, which is what stops the alarm latching on a deployment whose wiring is stable. So a store that was down for ten minutes produces an alarm ten minutes long and then a clean posture, with no intervention and no restart.
A push this instance refuses is the case that does not clear itself, and that is correct: reading the tables is not the same as adopting them. The alarm stays, and its age keeps counting from the first refusal, until either the push is corrected or the instance is restarted. Those two are the remedies the code's own fixups name.
Restarting is still the way to force the matter, and it is safe in the direction that counts: a boot re-reads the shared wiring from scratch and refuses to start on wiring it cannot build, rather than starting degraded.
Symptom to first look
| What you see | Where to look first |
|---|---|
| A push had no effect on an instance | Is polling on there at all? A boot-only instance is correct and needs a restart. WiringPosture.Polling says. |
| Some instances answer differently from others | Expected for up to one interval after a push. Compare WiringPosture.Digest across the fleet; if they disagree for longer than the interval, at least one is stale. If they AGREE and the answers still differ, the difference is not in the shared wiring — compare WiringPosture.LocalDigest, which is the digest of each instance's own local document. Two instances agreeing on BOTH were built from the same configuration. |
APERTURE_WIRING_RESTART_REQUIRED on every tick | A connection name was added or dropped. Follow the three-step rollout above — and remember nothing else in that push has applied either. |
APERTURE_WIRING_CONNECTION_UNROUTED at boot | That instance has no route for a name the deployment declares. Supply it, then start. |
APERTURE_WIRING_REFRESH_FAILED itself | Nothing underneath it carried a code, which is worth reporting. Usually the store is unreachable from that host. |
| A stale alarm that is minutes old | Ordinary — a restarting database. The number to act on is the duration, not the boolean. |
| A stale alarm on an instance you just stopped | Not expected: a refresh this process's own shutdown cancelled records nothing. If you see one, the store really did go away first. |
| A stale alarm that is hours old | The fleet is enforcing policy somebody already retired. Treat it as a drifted deployment, not an outage: decisions are still being made, just from old wiring. |
What it costs
Both halves are measured, and the numbers are committed in docs/benchmarks.md
alongside the rest of the NFR suite. They come from two gated cases,
TestCheckNFRWiringPoll and TestCheckNFRAfterAWiringSwap, that the one documented
invocation already runs:
APERTURE_BENCH_ASSERT=1 go test -run TestCheckNFR ./bench/
The poll loop. Measured with a live loop doing a tick's work every 1 ms — thirty thousand ticks for every one the 30s default makes — the loop's cost is visible but small. Across two runs the decision path's p99 stayed inside the run-to-run spread of the same fixture with no loop at all (0.27–0.32 ms, against a 1 ms budget it clears by 3×), and single-goroutine throughput ran 5–15 % lower (12 900–14 700 checks/sec, against a 10 000 floor). That is the cost of a loop being paid thirty thousand times more often than any deployment pays it; at an interval you would actually deploy it is a thirty-thousandth of that, which is not a number you can measure or plan around.
The swap. A new version's caches start empty — fresh per-type metadata
caches and fresh per-slot attribute caches. That is a security property before it is
anything else: a rebuilt slot must never answer from an entry fetched under the
superseded configuration's ttl:, which is the window a revoked clearance would
otherwise keep authorizing for. The price is a brief cold period after every push.
On the benchmark fixture it is small and bounded: the first decision on a new version
cost 2.6× the warm median (129–134 µs against 49–52 µs across two runs), the window
settled within fifty to ninety decisions, and the whole cold period cost
0.7–1.1 ms of extra work — about one decision's worth of the 1 ms p99 ceiling, once,
per push.
One caveat, because the fixture cannot stand in for your deployment: those figures
are for in-memory providers, where filling a cache entry is cheap. With kind: sql
providers, the first decision that touches each object type after a push pays a real
query round trip to fill that type's entry. The cold cost then scales with how many
object types get touched, not with microseconds — still one round trip per type per
push, and still nothing that accumulates. If you are pushing wiring at human cadence
this is not a number you need to manage; if something is pushing wiring on a timer,
the cold period is one of the reasons not to.
Related
- serve — the flag, the value vocabulary, what a swap replaces and what a tick costs.
- Two instances, one store — standing the fleet up: one model, one pushed wiring, per-instance connection routes.
- wiring — the
push/show/pull/diffcommand tree. - Global options — where a booting instance's wiring comes from, and the three connection routes.
- Performance & NFR — the decision hot-path budget these numbers are measured against.
- Troubleshooting — reading an
APERTURE_*error and acting on its fixups.
Performance & the NFR
Aperture's decision hot path carries a hard success metric (FR-31):
p99 cached
Check< 1 ms and ≥ 10 000 checks/sec/instance.
This chapter summarizes how that budget is measured and asserted. The full
methodology, the optimization pass, and the committed hardware numbers live in
the repository at docs/benchmarks.md (repo root, alongside the book — it is
not part of the mdBook source tree, so read it directly in the repo or on GitHub).
The benchmark suite (make bench)
The suite lives in the bench/ package. It seeds a sizable authorization
model — not a three-grant toy — and drives the full decision facade
(service.Service.Check), so the numbers reflect what a real surface pays.
The fixture seeds 8 accounts, 60 roles, 60 groups, and 480 principals, with
overlapping wildcard allows, more-specific deny carve-outs, and 60 concrete
document grants per account. The representative cached Check resolves a
six-subject subject set to roughly 73 applicable grants at differing
specificities, so deny-overrides and the specificity tiebreak genuinely run
rather than short-circuiting.
make bench # go test -run '^$' -bench=. -benchmem ./bench/
make bench is informational — it prints, but never asserts:
| Benchmark | Reports |
|---|---|
BenchmarkCheckCachedAuditOff / …AuditOn | single cached Check ns/op, allocs/op, and a computed p99-ns |
BenchmarkCheckThroughputAuditOff / …AuditOn | sustained parallel throughput as checks/sec |
BenchmarkEnumerateBounded | an Enumerate on an engine that configures no bound; asserts the result never exceeds engine.DefaultEnumerateLimit |
BenchmarkEnumerateRuleBacked/candidates-N | a rule-backed Enumerate swept across candidate-set sizes on the default bound, reporting a derived ns/candidate and the returned ids count |
BenchmarkEnumerateRuleBacked/candidates-N/bound-M | the same sweep at a bound raised through engine.WithEnumerateLimit — the rows that answer "what does raising the bound cost?", from the same invocation as the default ones |
BenchmarkEnumerateRuleBackedRuleEval | the same count of rules.Engine.Selected calls with no engine around them, separating the rule half of the enumeration cost from the decision half |
The audit toggle is the axis: audit-off is the s.audit == nil path;
audit-on wires a sampled (1 %), asynchronous audit.Recorder — the production
shape where decision audit sits off the critical path.
The hard NFR gate (TestCheckNFR)
Wall-clock assertions are environment-sensitive, so the hard gate is a test
that is off by default and never runs in the routine make test. It
self-skips under go test -short and skips unless APERTURE_BENCH_ASSERT=1
is set. Run it explicitly on a known-unloaded machine:
APERTURE_BENCH_ASSERT=1 go test -run TestCheckNFR ./bench/
Inside the gate:
- p99 — time 100 000 cached
Checks on a warm engine, split into 10 rounds of 10 000; sort each round's per-op latencies and take its 99th percentile; assert the lowest round's p99 is< 1 ms. - throughput — run 200 000 cached
Checks, split into 50 rounds of 4 000; time each round; assert the fastest round's rate is≥ 10 000 checks/sec(a conservative single-goroutine floor; a real instance parallelises well above it). - both are run with audit on and off.
The rounds partition the sample budget rather than multiplying it — the gate
performs the same total number of Checks a single contiguous measurement did —
and taking the best round is what widens an absolute wall-clock threshold's
margin on a machine that is doing other things. It widens the margin rather than
conferring immunity: measured A/B with the same competing load held across both
arms, the contiguous measurement cleared the 10,000 floor by 1.10× where
best-of-rounds cleared it by 1.31×, and past roughly 10x core oversubscription
both fail, because no window is uncontended and there is no clean round to take
the best of. Contention is one-sided: it only ever makes
a window slower, never faster. Measured as one contiguous window, pressure
anywhere in that window drags the whole average under the floor, and the gate
then reports a fact about the machine rather than about Aperture. Measured as
rounds, a contended window costs its own round and the best round still reports
what a decision costs when it has a core — which is the quantity the NFR is
about. Two round counts rather than one because throughput is a rate (many
short windows, so one is likely to land uncontended) and p99 is a percentile
(fewer, larger windows, or the estimator itself goes noisy).
The trade is explicit: a regression that is intermittent in exactly the shape load noise has — slow in most windows, fine in one — can now pass. A uniform one still fails, because it misses the target in every round. The failure messages say which case you are looking at ("that is the BEST of N rounds, so it is not one noisy window"), so a red gate is never dismissible as a bad second.
TestCheckNFR is the regression guard: it fails if p99 ever crosses 1 ms or
throughput drops below the floor. Because it is gated it never flakes the default
build, but it is wired and runnable on demand and in a dedicated CI job/cron
where the runner is known to be idle.
-run is an unanchored regexp, so that one invocation also picks up
TestCheckNFRCollections, TestCheckNFRAttributes, TestCheckNFRWiringPoll,
TestCheckNFRAfterAWiringSwap and
TestCheckNFREnumerateBound — the last of which guards the
enumeration bound and is the one threshold in the suite
that is not a wall clock. It holds a rule-backed Enumerate at a raised
bound to a ratio against the same enumeration at the default bound
(per-candidate cost within 1.5×), measuring both arms on the same machine in the
same second. An 11.6 ms enumeration is correct by design at a bound of 2 000, so
no absolute number could be asserted there; what must not change is that the cost
stays linear in the bound.
The shared-wiring poll and swap
A long-lived serve can be told to re-read the
shared wiring tables on an
interval and adopt a change without a restart. Two of the cases above exist because
that machinery has to be invisible to the numbers on this page, and "invisible
by construction" is a claim rather than a measurement:
TestCheckNFRWiringPollre-runs the gate's own assertion over the same fixture and query with a live poll loop underneath it, doing a tick's work every 1 ms — thirty thousand ticks for every one the 30s default would make. At that rate the loop's cost is visible but small: across two runs the p99 stayed inside the run-to-run spread of the arm with no loop (0.27–0.32 ms against a 1 ms ceiling) and throughput ran 5–15 % lower (12 900–14 700 checks/sec against a 10 000 floor). Divided by the 30 000× between that interval and the default, the same work is not a measurable quantity. The case logs and asserts its tick count, so a green run cannot be one where the ticker never fired.TestCheckNFRAfterAWiringSwapmeasures what an adopted push costs the decisions that follow it. A swap installs a version whose caches start empty, on purpose — a rebuilt attribute slot must never answer from an entry fetched under the superseded configuration'sttl:— so there is a cold period after each push. On the fixture the first decision on a new version cost 2.6× the warm median (129–134 µs against 49–52 µs), the window settled within fifty to ninety decisions, and the whole cold period cost 0.7–1.1 ms above steady state — about one decision's worth of the 1 ms ceiling, once, per push. The case asserts that a swapped version clears the same p99 and throughput targets a booted one does, which is the regression that matters: a swap that installed a permanently colder stack would show up here and in no other case, because every other one measures a stack that booted.
Neither figure is a reason to choose an interval. The interval is a staleness
budget — how long two instances may answer differently — and the operator-facing
account of that, and of the cold period under kind: sql providers where a cache
entry costs a query round trip, is
Refreshing wiring on a live fleet.
Committed numbers
Measured on an Apple M1 Max (go test -benchtime=2s). Absolute numbers are
hardware-dependent; the durable signal is the headroom and the allocation
profile.
Metric (cached Check) | audit off | audit on |
|---|---|---|
| mean latency | ~66 µs/op | ~70 µs/op |
| allocations | 34 allocs/op | 34 allocs/op |
| p99 (gated, 100k samples) | ~0.275 ms | ~0.265 ms |
| throughput (single goroutine) | ~15 100 checks/sec | ~14 700 checks/sec |
| throughput (parallel benchmark) | ~20 000 checks/sec | ~30 000 checks/sec |
Both targets are met with comfortable headroom — p99 sits ~3.6× under the 1 ms ceiling, and even the single-goroutine throughput clears the 10 k/s floor by ~1.5× before any parallelism. Audit-on does not regress the target: sampling is a single call on the un-kept path and the kept event is built lazily and written asynchronously, so the decision never blocks on audit.
The enumeration bound
The numbers above are Check, which asks about one object. Enumerate asks
about a population, and every candidate on the way to the result costs a full
deny-overrides evaluation — a Check's worth of work each. The enumeration
bound is what caps that. It governs how much work a single decision may do, not
merely how long a list it may print, and it is the one performance number an
operator sets.
Setting it
| Flag | --enumerate-limit |
| Environment | APERTURE_ENUMERATE_LIMIT |
| Default | 1000 |
| Precedence | flag > env > default |
bin/aperture serve --enumerate-limit 2000
APERTURE_ENUMERATE_LIMIT=2000 bin/aperture serve
It configures the process, not the server. check, enumerate,
identifiers, explain and mcp carry the same flag and read the same
variable, so one binary can never answer 2000 over HTTP and 1000 at the
shell. (aperture attributes is the deliberate exception: it builds the same
decision stack, but everything it prints is paged by the attribute registry's own
cap, which this bound never governs.)
A value that is not a whole number greater than zero — banana, 0, -5 —
fails the command with APERTURE_CONFIG_INVALID naming the setting and the value
it rejected, rather than quietly serving 1000. Under serve that refusal
happens before the store is opened, so a rejected configuration leaves no
database file behind. To get the default, omit the setting.
What the number means at request time: a request limit above it is clamped down to it, a request limit at or below it is honoured as asked, and a non-positive request limit receives it. The same value also bounds the scope member gather, so the gather and the result cap are one number rather than two that could disagree. See Deployment, Global options and Decisions.
What raising it costs
Measured with BenchmarkEnumerateRuleBacked on the worst case on purpose:
one account-wide inclusive;rule=… grant, a scalar comparison over one metadata
field, every candidate selected (a rejected candidate is cheaper, because it
skips the second evaluation), audit off. Apple M1 Max (10 cores), go1.26.5
darwin/arm64, -benchtime=1s -count=3, medians. bound is the value passed to
engine.WithEnumerateLimit; — means unconfigured.
| candidates | bound | ns/op | ids | ns/candidate | allocs/op | B/op |
|---|---|---|---|---|---|---|
| 10 | — | 44 359 | 10 | 4 436 | 496 | 39 311 |
| 100 | — | 436 078 | 100 | 4 361 | 4 679 | 385 291 |
| 1 000 | — | 5 058 189 | 1 000 | 5 058 | 46 137 | 3 870 603 |
| 2 000 | — | 4 733 331 | 1 000 | 4 733 | 46 141 | 3 904 836 |
| 1 000 | 2 000 | 4 543 591 | 1 000 | 4 544 | 46 131 | 3 870 590 |
| 2 000 | 2 000 | 11 565 568 | 2 000 | 5 783 | 92 188 | 7 816 610 |
| 4 000 | 2 000 | 9 170 151 | 2 000 | 4 585 | 92 168 | 7 876 370 |
Four things to take from it:
- The cost is linear in the bound, not worse. 1 000 → 2 000 returned ids is
2.00× the allocations and 2.02× the bytes, and
ns/candidatestays flat (4 361–5 783) across three orders of magnitude of population and across both bounds. Doubling the bound doubles the worst case and no more. - Budget roughly 4.6 µs, 3.9 KB and 46 allocations of transient garbage per id
the bound allows. A bound of 2 000 is a ~9 ms, ~7.8 MB enumeration; a bound
of 10 000 is a ~46 ms, ~39 MB one. Even at the default, a rule-backed
Enumerateis ~1 000× a cachedCheck— it is an interactive operation, not a hot-path one, and raising the bound scales that latency with it. - Headroom a deployment does not use costs nothing. 1 000 candidates at a
bound of 2 000 is indistinguishable from the same population unconfigured —
46 131 vs 46 137 allocations, the same
B/opto four digits. Configuring room you have not grown into is not paid for. - A raised bound clamps exactly as the default one does. 4 000 candidates at a bound of 2 000 costs what 2 000 at that bound costs; past the bound the extra objects are never visited. The worst case stays a constant a host can budget for — it is simply a constant the operator now chooses.
Read the ratios, not the absolutes. Wall-clock figures here move with
whatever else the measuring machine is doing — the same benchmark measured 2 348
ns/eval on a loaded machine and 1 500 ns/eval on a quiet one, before any code
changed. (The one durable
change since these were first recorded is ~3 extra allocations per rule
evaluation, added deliberately in 2026-08 for the attribute floor bags; the
repository's docs/benchmarks.md accounts for it.) Treat the
per-id figures as a shape to size a deployment with, not as a performance promise
for your hardware — and measure your own with make bench before committing to a
number. The full methodology and the rest of the sweep are in the repository file
docs/benchmarks.md.
Truncation is a log line, not a result field
When an enumeration comes back holding exactly its effective bound, the engine emits a WARN naming the bound that was hit:
WARN engine: enumeration returned exactly its bound; the result may be truncated
bound=2000 configured_bound=2000 requested_limit=0
account=acme action=read pattern=account:acme/**
Read it literally: it is a hint, not an assertion. A complete set that
happens to be exactly that size is indistinguishable from a truncated one, so the
line never claims anything was dropped. Enumerate returns ([]string, error)
and grows no truncation flag, which means a caller cannot tell a truncated
result from a complete one — the signal is the operator's, and the response is
to raise the bound and ask again. A result below the bound logs nothing.
Two things the bound deliberately does not cap
Both are accepted consequences, not gaps:
- A direct Go embedder gets the limit it asks for.
provider.Registry.Listhonours a positive limit verbatim, however large —provider.DefaultListLimit(1000) is only what a non-positive limit means. A host callingreg.List(ctx, t, pat, 1_000_000)is asking deliberately and is answered. This is not a hole: every network surface reaches enumeration through the engine's clamp, and the library is sharp-edged on purpose. provider.AttributeRegistry.Enumerateis uncapped. It is a system-tier directory read — pulling a whole directory is the point — and it is protected by the administrator authority it demands, not by a number. That is whyaperture attributescarries no--enumerate-limit: this bound never governed what it prints.
The full statement of both lives in the
decision API and in
skills/decision-api.md; this page restates them because they are the two places
where "the bound caps enumeration" is not the whole truth.
The honest caveat: batching against SQLite
Raising the bound costs more under EnumerateBatch against a file-backed SQLite
store than the table above suggests. The SQLite pool is capped at a single
connection (writes serialize cleanly under SQLite's single-writer model, and
reads pay for it), and EnumerateBatch is today a batch in call shape only — it
runs each request in turn, re-resolving membership, the subject set and the grant
query per item. Store round trips, not the candidate walk, dominate: the same
enumeration costs ~0.026 ms/pattern against the in-memory store and ~0.504
ms/pattern against file-backed SQLite, and end-to-end latency is linear in
concurrent callers.
That is not fixed by this bound's configurability and is out of scope here; it is tracked as issue #13. Until it moves, size a raised bound against your batch fan-out and your concurrency, not against a single enumeration in isolation — and note the raised-bound gate uses the in-memory store, so it does not exercise this amplifier at all.
Where the headroom came from
The optimization pass (recorded in docs/benchmarks.md) found the dominant
per-Check allocator: the coverer re-parsed each grant's object pattern on every
candidate of every Check, so a principal resolving ~73 grants paid ~73 fresh
pattern parses. A concurrency-safe parsed-pattern cache in the engine
(engine/patterncache.go) removed the churn — a parsed pattern is immutable and a
pure function of its source, so a cache hit returns exactly what a fresh parse
would and decision semantics are unchanged. Effect: 172 → 34 allocs/op
(~5× fewer), with the re-parse GC pressure gone from the hot path.
The change was measure-first: caches that already bound their own cost (the compiled-rule cache, the provider metadata cache) were left untouched absent a benchmark showing a win.
Related
- Repository file
docs/benchmarks.md— the authoritative methodology, the optimization write-up, and the latest committed numbers. - Deployment — running the instance whose throughput these
numbers describe, and where
--enumerate-limitsits among the other settings. - Refreshing wiring on a live fleet — the poll and the swap from the operator's side, including the cost paragraph these two cases feed.
- Global options and
Decisions — the bound at the command line, and how a
request's
--limitinteracts with it. - Decision API —
WithEnumerateLimit, the on-the-bound warning, and the two uncapped reads in full. - Rules engine, Providers — the caches referenced by the measure-first note.
Troubleshooting
Audience: operators diagnosing a failed request, boot, or CLI command.
Every failure Aperture surfaces is an APERTURE_* coded error. The code — not
the human-readable message — is the stable contract, and each code carries
operator-actionable fixups in the error registry. Troubleshooting Aperture is
therefore mostly: read the code, look it up, apply its fixup.
Reading an APERTURE_* error
An error prints its code alongside a short summary, for example:
APERTURE_CONFIG_INVALID: auth: unknown auth mode
or, when it wraps a lower cause, the code is preserved from wherever it was first
stamped (the wrappers never re-stamp an already-coded error), so the code you
see is the precise failure class — a provider returning APERTURE_NOT_FOUND for
an absent object surfaces as APERTURE_NOT_FOUND all the way up.
Two properties make the code trustworthy:
- Stable and machine-readable. SCREAMING_SNAKE,
APERTURE_-prefixed. The CLI, HTTP/Twirp, and MCP surfaces map the code to a transport status without string-matching the message. - Leak-free. A code and its message never carry another account's ids, names,
or contents — cross-account isolation is a hard invariant. Narrowing detail
lives in the structured
Contextmap, not in interpolated message text, so it is safe to log and share the message.
Acting on the fixups
Every code has exactly one entry in the registry (errors.Registry), and that
entry carries either at least one fixup — a concrete next step — or is marked
FixupNotApplicable when no action is meaningful. The generated
Error Codes reference renders the full table:
each code, its canonical message, and its fixups.
The workflow:
- Note the
APERTURE_*code from the output or logs. - Find it in the Error Codes reference.
- Apply the listed fixup(s).
A few codes you are likely to meet operating a service:
| Code | Typical trigger | First move |
|---|---|---|
APERTURE_CONFIG_INVALID | An unrecognised APERTURE_AUTH_MODE / --auth, or bad adapter config at boot. | Check the auth env vars against Deployment; valid modes are dev | oidc | parsec. |
APERTURE_BOOT | serve failed to wire up for a reason no layer below classified — provider build, the auth adapter, or the listener. | Read the wrapped cause; verify --store DSN, --seed path, and the seed's providers: section. |
APERTURE_STORAGE_SCHEMA_INCOMPATIBLE | The database at --store was written by an older build of Aperture. | Aperture ships no migration path. Move or delete the old database, let Setup create a fresh one, and re-seed it. |
APERTURE_STORAGE_CONSTRAINT | A delete would have orphaned rows, a write named something that does not exist, or the SQLite connection is not enforcing foreign keys. Over the RPC surface this is a 412, not a 500 — it is a caller-order problem, not a broken server, so do not page on it. | Remove the children before the parent (or create what a record references first). From Setup, open the store with sqlite.Open, which forces _pragma=foreign_keys(1). |
APERTURE_UNAUTHENTICATED / APERTURE_INVALID_TOKEN | A request carried no bearer, or a token that failed verification. | Confirm the client credential and the configured adapter (dev treats the bearer as the principal id). |
APERTURE_AUTHZ_DENIED | The caller lacks the admin tier for a gated mutation. | Expected for under-privileged callers; grant the tier or use an authorized principal. |
APERTURE_NOT_FOUND | A referenced grant, rule, object, or entity does not exist (or is out of the caller's account scope). | Re-check the id; remember cross-account lookups are scoped, so another account's entity reads as absent. |
The reference table is the authority for the exhaustive list and the exact fixups — the rows above are orientation, not a substitute.
When the fixup is not enough
- The message is a summary; the wrapped cause (visible when the CLI prints the
chain) and the
Contextmap hold the specifics. - Boot failures under
serveare almost always configuration:--storeDSN,--seedpath/format, provider files declared in the seed, or an auth adapter that can't reach its IdP at startup (oidcdiscovers at boot). See Deployment. - If a decision is slow rather than wrong, that is a performance question —
see Performance & the NFR and the
TestCheckNFRgate.
Related
- Error taxonomy — the coded-error type, the wrapping rules, and the registry gates.
- Error Codes reference — the generated table of every code, message, and fixup.
- Deployment — the config surface most boot errors point back to.
Architecture
Audience: contributors and integrators who want to understand how Aperture is put together before extending it.
Aperture is a policy decision point (PDP): a single engine that answers "is
this principal allowed to do this thing to this resource, and why?" This page
sketches the shape of the codebase and the one tenet that governs it. The
authoritative statement of the project's conventions is
CLAUDE.md at the
module root — when this page and CLAUDE.md disagree, CLAUDE.md wins. This
page stays deliberately thin so it does not drift from that source of truth.
The one tenet: surfaces are thin translators
There is exactly one place a decision is made. Everything a caller can touch —
the aperture CLI, the Twirp/HTTP RPC API, the MCP server, and the admin UI — is
a thin translator over one decision engine. A surface's only job is to turn
its wire format into a decision request, hand it to the engine, and render the
result back out. No surface re-implements policy logic.
The payoff is consistency: the answer a shell script gets from the CLI is the
same answer a service gets over RPC and an agent gets over MCP, because all three
ride the same Check / Enumerate / Explain path.
Library-first
The product is the public Go packages at the module root
(github.com/frankbardon/aperture) — not the binary. cmd/aperture/main.go is a
tiny adapter that calls internal/cli.NewApp; it holds no business logic. This
is the "library-first" rule: business logic lives in the root packages, and the
serve command wires them together with manual dependency injection (no
wire/fx/dig).
Package boundaries
flowchart TD
subgraph Surfaces["Surfaces (thin translators)"]
CLI["internal/cli<br/>(urfave/cli/v3)"]
RPC["internal/server + internal/wire/rpc<br/>(net/http + Twirp)"]
MCP["mcp/ + mcp/gosdk<br/>(SDK-free core + adapter)"]
UI["internal/server/static<br/>(admin UI)"]
end
Facade["service/<br/>decision facade"]
Engine["engine/<br/>Check · Enumerate · Explain"]
subgraph Domain["Decision domain (root packages)"]
Rules["rules/"]
Scope["scope/"]
Provider["provider/ · csvprovider/ · sqlprovider/"]
Identity["identity/"]
Model["model/"]
Filter["filter/"]
Auth["auth/ · authz/"]
Audit["audit/"]
Deleg["delegation/ · impersonation/"]
end
Storage["storage/<br/>Storage interface + sqlite/memory"]
Errors["errors/<br/>APERTURE_* coded errors"]
CLI --> Facade
RPC --> Facade
MCP --> Facade
UI --> RPC
Facade --> Engine
Engine --> Domain
Domain --> Storage
Facade -.->|"every failure is a"| Errors
Engine -.-> Errors
Domain -.-> Errors
Read the arrows as "depends on / calls into". The errors/ package underpins
every layer: every failure that crosses a package boundary is an APERTURE_*
coded error (see Error taxonomy). The dependency graph
points downward — scope, provider, identity, and model are leaves
that the engine adapts to, never the other way round.
The decision API
The engine exposes three operations, each in a single and a bulk-batched form:
| Operation | Question it answers |
|---|---|
| Check | May this principal perform this action on this resource? |
| Enumerate | Which resources/actions is this principal allowed? |
| Explain | Why was a decision reached — which rules and grants applied? |
Explain is first-class, not a debugging afterthought: decisions are auditable
by construction. The Decision API and
Batch operations chapters cover the library surface;
The service facade is the seam every surface
translates into.
Constraints that shape the code
These are hard rules — a change that breaks one is a defect:
- Pure-Go,
CGO_ENABLED=0end to end. No CGO packages (no geo/h3). - No dependency on Pulse. The rules engine renders its AST to an
expr-lang/exprexpression and compiles it in-process. See Rules engine. - No ORM / sqlc / migration tool. Storage is hand-written SQL over
modernc.org/sqliteplus an in-memory implementation behind oneStorageinterface. See Storage. - No bare errors across package boundaries. Wrap in an
APERTURE_*code. - No cross-account leakage through error messages.
Where to go next
- Package layout — what each root package owns.
- Extending Aperture — the "Adding a…" recipes.
- The Update-Demand rule — the docs-with-code house rule.
Package layout
Audience: contributors navigating the source tree for the first time.
Aperture keeps its public packages at the module root (like Pulse) rather
than under internal/. The root packages are the product; the internal/
packages are the surfaces and generators that translate to and from them. Each
row below links the concept chapter that explains the package's domain in depth —
this page is a map, not a re-explanation.
Root packages (the product)
| Package | Owns | Concept chapter |
|---|---|---|
errors/ | APERTURE_* coded errors; codes.go holds the Registry + AllCodes. The doc-generation source and CI gates live here. | Error taxonomy |
engine/ | The decision engine — Check / Enumerate / Explain, single and bulk. | (drives) Decision API |
service/ | The service facade over the engine; the surface-neutral Query/Overlay/Actor types every surface translates into. | The service facade |
rules/ | The rule AST → expr-lang/expr compiler + program cache. No Pulse import. | Rules engine |
identity/ | Principal and object identities and specificity-ranked patterns. | Identity patterns & specificity |
model/ | The RBAC domain model: object types, permissions, roles, groups, grants. | RBAC domain model |
scope/ | Pluggable scope-strategy resolvers (implicit / inclusive / exclusive) + a registry. | Scopes & scope strategies |
provider/ | The object-provider registry + per-type metadata cache, and the declared object references held on it. | Providers, Declared references |
csvprovider/ | A concrete ObjectProvider backed by a CSV file — the reference implementation. | Providers |
sqlprovider/ | A concrete ObjectProvider backed by a relational database, over a two-method Querier seam. Links no driver. | Providers |
auth/ | Authentication adapters (dev / OIDC / parsec) that turn a bearer into a principal. | Authentication |
authz/ | The authorization gate that surfaces call to guard mutations. | The authz gate |
audit/ | The decision/mutation audit log. | Audit trail |
delegation/ | Grant delegation ("bestow"). | Delegation |
impersonation/ | Scoped act-as-another-principal grants. | Impersonation |
filter/ | Scoped read-visibility filtering of entity lists. | Filtering entity lists |
seed/ | Seed/fixture data and portability import/export. | Seed & portability |
mcp/ | The SDK-free MCP core: typed tool contract + handlers over the facade. | MCP surface |
storage/ | The Storage interface + hand-written SQL (modernc.org/sqlite) + in-memory impl. | Storage |
Internal packages (surfaces and tooling)
| Package | Owns |
|---|---|
cmd/aperture/ | The binary entry point (main.go) and e2e tests. Thin — no business logic. |
internal/cli/ | The urfave/cli/v3 command tree (NewApp); the CLI-reference generation source. |
internal/server/ | net/http ServeMux + Twirp handlers + admin-UI static serving + middleware. |
internal/server/static/ | The admin UI (Alpine + Styles + Rete.js); vendor/rete/ is a committed JS bundle. |
internal/wire/rpc/ | service.proto plus the committed generated service.pb.go / service.twirp.go. |
internal/docsgen/ | The on-demand documentation generators (errcodes, cliref) run by make docs-gen. |
mcp/gosdk/ | The one adapter that imports the MCP protocol SDK; a firewall test keeps the core SDK-free. |
mcp/toolmeta/ | The pure-data tool identity table (names + descriptions) shared by the core and the adapter. |
bench/ | The performance suite and the TestCheckNFR gate (kept out of make test). |
skills/ | The Update-Demand surface docs and their coverage gates. See The Update-Demand rule. |
Dependency direction
The root packages form a layered graph pointing downward. scope, provider,
and identity are leaves: scope imports only identity and errors;
provider imports only identity and errors. The engine adapts the model onto
these leaves rather than the leaves reaching up. This is what lets you add a
scope strategy or a provider without touching the engine — the seams described in
Extending Aperture exist precisely because the dependency arrows
never point up.
Extending Aperture
Audience: contributors adding a new extension point to the engine or a surface.
Each recipe below is a short, concrete how-to grounded in the real code: the files to touch, the interface to implement, and the test or gate you must satisfy. They assume you have read Architecture and Package layout. Every recipe leans on a package's concept chapter for the why — follow those links rather than re-deriving the domain here.
Run make test (go test ./...), make vet, and make lint before you open a
PR. Some changes also trip the Update-Demand rule — a
surface change that lands without its skills/*.md doc is a CI failure.
Adding an error code
Concept: Error taxonomy. Files: errors/codes.go,
then make docs-gen.
-
Declare the constant in the
constblock inerrors/codes.go. Codes areSCREAMING_SNAKE,APERTURE_-prefixed, typedCode, with a doc comment explaining when it is raised:// APERTURE_WIDGET_JAMMED — the widget resolver could not advance. APERTURE_WIDGET_JAMMED Code = "APERTURE_WIDGET_JAMMED" -
Append it to
AllCodes— the slice every gate walks. -
Add a
Registryentry with aMessageand either at least oneFixuporFixupNotApplicable: true:APERTURE_WIDGET_JAMMED: { Message: "the widget resolver could not advance", Fixups: []string{"retry the request", "check the widget provider health"}, }, -
Regenerate the reference table:
make docs-genrerunsinternal/docsgen/errcodesovererrors.Registryand rewritesdocs/src/reference/error-codes.md(committed; no CI drift gate). Commit the regenerated file with your change.
Gates you must satisfy (in errors/codes_test.go):
TestCodesHaveFixups— every code has a Registry entry with a Message and a Fixup (orFixupNotApplicable).TestRegistryHasNoOrphans— the Registry contains nothing absent fromAllCodes.TestCodesAreScreamingSnakeNamespaced— every code isSCREAMING_SNAKEandAPERTURE_-prefixed.
Construct the error at the raise site with errors.New / Newf / Wrap /
Wrapf (or errors.WithContext for a details map); recover it with
errors.CodeOf. Any error already carrying an APERTURE_* code passes through
verbatim — the wrappers never re-stamp it.
Adding a scope strategy
Concept: Scopes & scope strategies. Files:
scope/ (a new resolver + factory), then register it on your scope.Registry.
A scope strategy decides a grant's object membership. Implement
scope.ScopeResolver:
type ScopeResolver interface {
Contains(ctx context.Context, object identity.Identity) (bool, error)
Members(ctx context.Context, pattern identity.Pattern) ([]identity.Identity, error)
}
Containsanswers the hot-path question "is this concrete object a member?" and must never enumerate.Membersperforms a bounded enumeration (bounded by theDeps.MaxMembersthe factory was handed, orscope.DefaultMaxMemberswhen that is zero) forEnumerate-style callers, and must agree withContains— anythingContainsaccepts belongs in the member set, or the decision endpoints contradict each other. If it needs to list "all objects of a type" (including to filter a non-invertible predicate list-then-filter), it consults the injectedscope.ObjectLister; when none is configured, returnAPERTURE_SCOPE_LISTER_UNCONFIGUREDrather than an empty set.
Provide a scope.Factory that validates the parsed scope.Spec for your
strategy and captures the GrantContext + Deps:
func newWidgetResolver(gc scope.GrantContext, deps scope.Deps) (scope.ScopeResolver, error) { … }
Register it under a key on a scope.Registry (Register/MustRegister). Reuse
the built-ins with scope.DefaultRegistry() and add yours, or start from
scope.NewRegistry(). A resolver never computes specificity — that stays the
pattern's job in the engine. Cover Contains and Members with a table test
alongside scope/resolvers_test.go; an unregistered key surfaces
APERTURE_SCOPE_UNKNOWN_STRATEGY, a bad spec APERTURE_SCOPE_INVALID.
Adding an object provider
Concept: Providers. Files: a new package (see
csvprovider/ for a file-backed reference impl and sqlprovider/ for a
database-backed one), then register it on a provider.Registry.
A provider is the host's pull source for one object-type. Implement
provider.ObjectProvider:
type ObjectProvider interface {
Fetch(ctx context.Context, id identity.Identity) (Metadata, error)
List(ctx context.Context) ([]Object, error)
Query(ctx context.Context, filter Filter) ([]Object, error)
}
Fetchreturns an object'sMetadata(amap[string]any); a missing object must return anAPERTURE_NOT_FOUNDcoded error so the Registry can distinguish "absent" from a fault. A plain error is wrapped asAPERTURE_PROVIDER_FETCH.- Return a fresh map per object — cached
Metadatais treated as read-only and is never copied on read, so a shared map would race readers. Listis the unfiltered enumeration;Queryhonours aprovider.Filter(Pattern,Fields,Limit). Aperture re-enforcesPatternandLimiton the results, so a provider that ignores them is still correct, only slower.
Register it under its object-type key on a provider.Registry
(provider.NewRegistry(...)), which pairs each provider with a per-type metadata
cache. A *provider.Registry also satisfies scope.ObjectLister, so it wires
directly into the scope resolvers above. Mirror csvprovider/csvprovider_test.go
for coverage.
Two rules a new provider inherits rather than invents. Query must evaluate
Filter.Fields exactly as provider.MatchFields does — call the helper unless
you have a reason not to, because a provider that filters differently authorizes
differently. And every value it produces must satisfy the
metadata value model;
validate at load with provider.ValidateMetadata so a bad shape fails where the
data enters instead of on the Check hot path. sqlprovider shows what that
costs when the source is untyped: its whole driver-value mapping exists to turn
whatever database/sql scanned into a value the model admits, and the cases it
cannot decide are pushed back onto the developer's SELECT list as casts.
Adding an auth method
Concept: Authentication. Files: auth/ (a new
adapter), then select it in the server wiring.
Authentication is always external — Aperture consumes credentials, it never
issues them. Implement auth.Authenticator:
type Authenticator interface {
Authenticate(ctx context.Context, bearer string) (principalID string, claims Claims, err error)
}
- Fail closed: a missing, malformed, or unverifiable credential returns
APERTURE_UNAUTHENTICATED(no principal derivable) orAPERTURE_INVALID_TOKEN(credential failed verification) — never a silently-empty principal. - Resolve the principal through the shared claim→principal mapping so "which
claim is the principal id" stays configuration, matching the
oidcandparsecadapters (thedevadapter is the one exception — the bearer is the principal). Followauth/oidc.go/auth/parsec.goas the models and add a*_test.gobeside them.
The middleware in internal/server extracts the bearer, calls Authenticate,
and attaches the resolved auth.Principal to the request context via
auth.WithPrincipal (recovered downstream with auth.PrincipalFromContext).
Adding an MCP tool
Concept: MCP surface. Files: mcp/toolmeta/meta.go,
mcp/contract.go, mcp/handlers.go, mcp/tools.go, mcp/schema.go.
The MCP core is SDK-free: it imports no MCP SDK. Adding a tool touches the pure-data identity table and the typed contract, and the go-sdk adapter picks it up automatically.
- Identity — add a name constant and a description constant in
mcp/toolmeta/meta.go, and a{Name, Description}row toMeta(). This is the single source of truth both the core and themcp/gosdkadapter read, so they never drift. - Contract — define the typed
In/Outstructs inmcp/contract.go(alias the facade's surface-neutral query types where possible). Keep the types non-cyclic; a field that would introduce a Go-level cycle must be typedanyso the JSON-Schema reflector stays error-free. - Handler — add a
func(context.Context, *service.Service, In) (Out, error)inmcp/handlers.go. Read-only only: every tool calls a facade READ or DECISION method (Check/Enumerate/Explain/Simulate/Get*/List*); no handler may mutate. - Wire it — add
toolmeta.ToolYours: makeInvoke(handleYours)to the map inmcp/tools.go, and aregister(...)call inmcp/schema.go'sinitso its input/output schemas are reflected.
Gates (mcp/surface_test.go, mcp/firewall_test.go):
TestCatalogMatchesToolmeta— the catalog matchestoolmeta.TestNoMutatingTool— no tool name carries a mutating verb (put/delete/create/update/bestow/revoke/grant/set/remove/write).TestSchemaReflectionClean— every tool's schema reflects without error.TestMCPCore_NoSDKImport— the core imports no MCP SDK (onlymcp/gosdkmay).
Adding a rule AST node
Concept: Rules engine. Files: rules/ast.go,
rules/compiler.go.
The rule AST is a small, closed node set that is both the engine's input and the node editor's serialization target — there is no second rule format, and its JSON form must round-trip byte-identically. To add a node type:
- Declare a
NodeTypeconstant inrules/ast.go(alongsideNodeAnd,NodeCompare,NodeVar, …). Add any fields it needs to theNodestruct. - Validate — add a
casetoNode.Validate()that checks the subtree is structurally well-formed. Validation runs before compilation and is what keeps the rendered expression injection-free, so be strict here. - Render — add a
casetoNode.render()(reached viaNode.Expr()) that emits the node'sexpr-lang/exprspelling. Rendering to an existing operator spelling means ASTs that render to the same expression share a compiled program in the cache.
The compiler (rules/compiler.go) validates, renders, and compiles once per
canonical hash. Keep evaluation pure: expose no wall-clock or random builtins.
If your node calls a function, it must be one of the curated pure functions or
one a host explicitly registers via the rules.Function(name, fn) compiler
option. Add round-trip and render tests beside rules/ast.go.
Adding an RPC
Concept / reference: RPC / HTTP overview,
RPC reference. Files:
internal/wire/rpc/service.proto, the committed generated *.pb.go /
*.twirp.go, internal/server/twirp.go, and the hand-authored RPC reference doc.
- Declare the method (and any new request/response messages) on
ApertureServiceininternal/wire/rpc/service.proto. - Regenerate the Twirp + protobuf code with
make proto(requiresprotocprotoc-gen-go+protoc-gen-twirp). The generatedservice.pb.go/service.twirp.goare committed — CI does not regenerate them — so commit the regenerated files with your change.
- Implement the method on
twirpHandlerininternal/server/twirp.go. The handler is a thin translator: decode the request, resolve the actor/principal from context, call theservice.Servicefacade, and encode the result. ReturnAPERTURE_*coded errors verbatim; never put policy logic here. - Document it — the RPC reference is hand-authored over
service.proto(there is no generator; drift is accepted). Add the new method todocs/src/surfaces/rpc-reference.md. - Cover it — add a smoke test beside the matching
internal/server/*_smoke_test.go.
The Update-Demand rule
Audience: contributors changing a registered surface.
Aperture has one house rule about documentation: any change to a registered
surface must ship the matching skills/*.md document in the same PR. A surface
change that lands without its doc update is a non-skippable CI failure. This page
is descriptive — it explains the rule and the gates that enforce it. The rule
itself lives in skills/update-demand.md and is self-protecting; this page does
not modify skills/*.md or the gates.
Why the rule exists
A surface's behaviour and its documentation are two halves of one contract. If
they can drift, the docs rot silently and callers get surprised. Update-Demand
removes the option to drift: the same PR that changes a surface changes its doc,
and CI refuses the PR otherwise. The skills/ docs are the human- and
agent-readable description of each surface; keeping them lockstep with the code is
the whole point.
What the rule requires
| If you change… | You must also update… | Enforced by |
|---|---|---|
| An Aperture error code | errors/codes.go — the AllCodes slice and a Registry entry with a Message + Fixups | TestCodesHaveFixups |
A skills/*.md doc | its YAML frontmatter (name matching the file stem + description) | TestEverySkillHasFrontmatter |
| The Update-Demand rule | skills/update-demand.md (it must stay present with frontmatter) | TestUpdateDemandDocPresent |
A table that exists in two places — the rule AST vs. the served rules-serializer.js, or the SQL driver-value type switch vs. mappedDriverTypes | the other half, plus the skills/ doc that restates it | the registry-parity tests, e.g. TestEditorOperatorTablesAgree and TestDriverValueMappingTableMatchesTheTypeSwitch |
CLAUDE.md carries the full table, row by row. As real surfaces land, each adds
a skills/<feature>.md doc and a coverage gate in skills/skills_test.go that
walks that surface's registry, plus a row there.
The enforcing CI gates
These gates are non-skippable and stay green regardless of any single change — they are the mechanical enforcement behind the rule:
TestCodesHaveFixups— everyAPERTURE_*code has aRegistryentry with aMessageand at least oneFixup(orFixupNotApplicable: true). Adding an error code without its remediation metadata fails here. See Adding an error code.TestRegistryHasNoOrphans— theRegistrycontains nothing that is absent fromAllCodes; the two lists cannot diverge.TestCodesAreScreamingSnakeNamespaced— every code isSCREAMING_SNAKEandAPERTURE_-prefixed.TestUpdateDemandDocPresent— the Update-Demand seed doc (skills/update-demand.md) exists with frontmatter. The rule's own documentation cannot be deleted without failing CI — this is what makes the rule self-protecting.TestEverySkillHasFrontmatter— everyskills/*.mdhas aname(matching its file stem) and adescriptionin its YAML frontmatter.- The registry-parity gates — they read the mirror of a Go table (a served JS
file, or the Go source itself) and fail rather than skip when it is
missing.
TestDriverValueMappingTableMatchesTheTypeSwitchis the newest: it parsessqlprovider/values.gowithgo/astand fails if the driver-value type switch andmappedDriverTypesdisagree, so the mapping a developer reads and the mapping the code performs cannot drift.
How this differs from the doc generators
Do not confuse Update-Demand with the mdBook doc generators. The error-code table
(docs/src/reference/error-codes.md) and the CLI reference
(docs/src/reference/cli.md) are regenerated on demand with make docs-gen and
have no CI drift gate — you regenerate and commit them yourself. Update-Demand
is different: it is a hard CI gate on the skills/*.md surface docs and the error
Registry. The generators keep the reference book fresh; Update-Demand keeps
the surface skill docs honest.
The full conventions catalog, including the authoritative Update-Demand table,
lives in
CLAUDE.md.
Development setup
Audience: new contributors setting up a local Aperture checkout.
Aperture is library-first and pure-Go end to end. There is no code generation on the critical build path, no CGO, and no external services required to build, test, or run the binary. If you can build Go, you can build Aperture.
Toolchain
| Tool | Version | Why |
|---|---|---|
| Go | 1.26.1 | The module targets this toolchain (go.mod). |
CGO_ENABLED | 0 (hard requirement) | Aperture consumes only pure-Go dependencies — it uses expr-lang/expr for rules, modernc.org/sqlite for storage, and has no Pulse dependency. CGO stays off so builds are static and cross-compile cleanly. The Makefile exports CGO_ENABLED=0 for every target. |
| mdBook | v0.5.2 | Only needed to build the documentation site (this book). Not required for the Go build or tests. |
Optional, only if you want the full local gate:
| Tool | Purpose |
|---|---|
staticcheck | Static analysis for make lint. CI installs it; locally make lint degrades to go vet when no usable analyser is on PATH — absent, or present but unable to start. Install it with go install honnef.co/go/tools/cmd/staticcheck@latest. |
protoc + protoc-gen-go + protoc-gen-twirp | Only to regenerate the RPC layer with make proto. The generated code is committed, so you do not need these for a normal build. |
node | Only to rebuild the vendored Rete.js bundle with make vendor-rete. Never required by build/test/CI. |
Building
Clone, then build the binary:
git clone https://github.com/frankbardon/aperture
cd aperture
make build # produces bin/aperture (CGO off, -ldflags="-s -w" -trimpath)
make build is the default goal, so a bare make does the same thing. Run the
freshly built binary with make run, or invoke bin/aperture directly.
make run # build, then execute bin/aperture
make clean # remove the bin/ directory
Because the build is pure-Go, go build ./... also works if you prefer the raw
toolchain — but make build is the supported entry point (it sets the release
flags and keeps CGO_ENABLED=0).
Building the documentation
The book you are reading is built with mdBook. Install mdBook v0.5.2, then:
make docs # mdbook build docs → docs/book/ (gitignored output)
make docs-serve # live-reload preview, opens a browser
make docs-clean # remove docs/book/
Mermaid diagrams render client-side from vendored JavaScript
(docs/mermaid.min.js + docs/mermaid-init.js) — there are no mdBook
preprocessor plugins to install. The built output under docs/book/ is
gitignored; never commit it.
Next steps
- Build, test & lint gates — the commands and CI gates to run before a PR.
- Style & testing conventions — the house rules your code must follow.
- Regenerating artifacts — when and how to run
make docs-gen,make proto, andmake vendor-rete.
Build, test & lint gates
Audience: contributors preparing a change for review.
Run the local gates before every PR. They are fast, pure-Go, and require no services. CI runs the same commands plus the non-skippable gate tests described below.
The make targets
| Target | Runs | Notes |
|---|---|---|
make build | go build → bin/aperture | Default goal. CGO_ENABLED=0, -ldflags="-s -w" -trimpath. |
make test | go test ./... | The full unit/integration suite. Does not include the NFR benchmark gate (see below). |
make fmt | go fmt ./... | Formats the tree. |
make vet | go vet ./... | Standard vet checks. |
make lint | go vet + a static analyser | Runs staticcheck if present, else golangci-lint, else prints a notice and runs vet only. A golangci-lint that cannot start (exit 3 — typically built with an older Go than this module targets) degrades the same way rather than hard-failing, with a loud notice that static analysis did not run; a real finding (exit 1) still fails the target. CI installs staticcheck explicitly, so lint is real in CI even though it degrades locally. |
A minimal pre-PR loop:
make fmt
make test
make vet
make lint
The benchmark / NFR gate is separate
make test deliberately excludes the hard performance assertion so a loaded CI
machine never flakes the build. The informational benchmark suite and the gated
NFR test live under bench/:
make bench # informational: ns/op, p99, checks/sec
APERTURE_BENCH_ASSERT=1 go test -run TestCheckNFR ./bench/ # the hard NFR assertion
TestCheckNFR asserts p99 cached Check < 1ms and ≥ 10k checks/sec/instance.
See Performance & NFR and the committed numbers in
docs/benchmarks.md for methodology.
So is the real-Postgres integration test
make test passes with no database present — CI runs with no service
containers, so the SQL provider
is proved against a hand-rolled fake driver returning canned values. One test
talks to a real Postgres, and it is gated the same way the NFR assertion is:
APERTURE_PG_INTEGRATION=1 \
APERTURE_PG_DSN='postgres://user:pass@localhost:5432/db?sslmode=disable' \
go test -run TestPostgresIntegration ./seed/
Ungated it skips. Gated with a missing or empty APERTURE_PG_DSN it
fails rather than skipping — asking for the integration run and silently not
getting one is the outcome a gate must never produce. It creates and drops its
own table, so point it at a scratch database, and never put a DSN in a file.
It exists because a fake cannot prove the two things only the real driver can:
that pgx is linked into a CGO_ENABLED=0 binary and actually connects, and that
a real Postgres result set lands in the value model the way the mapping table
claims.
The non-skippable CI gates
Five gate tests protect Aperture's error taxonomy and its Update-Demand rule.
They are ordinary Go tests, so make test already runs all of them — you do not
need a special command. To run them in isolation:
go test ./errors/ -run 'TestCodesHaveFixups|TestRegistryHasNoOrphans|TestCodesAreScreamingSnakeNamespaced'
go test ./skills/ -run 'TestUpdateDemandDocPresent|TestEverySkillHasFrontmatter'
| Gate | Enforces | Trips when you… |
|---|---|---|
TestCodesHaveFixups | Every APERTURE_* code has a Registry entry with a Message and at least one Fixup (or FixupNotApplicable: true). | Add an error code without its remediation metadata. |
TestRegistryHasNoOrphans | The Registry contains nothing absent from AllCodes. | Add a Registry entry but forget to list the code in AllCodes (or vice versa). |
TestCodesAreScreamingSnakeNamespaced | Every code is SCREAMING_SNAKE and APERTURE_-prefixed. | Name a code apertureFoo or drop the APERTURE_ prefix. |
TestUpdateDemandDocPresent | The Update-Demand seed doc skills/update-demand.md exists with frontmatter. | Delete or de-frontmatter the rule's own documentation. |
TestEverySkillHasFrontmatter | Every skills/*.md has a name (matching its file stem) and a description. | Add or edit a skills/ doc without valid YAML frontmatter. |
The first three live in errors/codes_test.go; the last two enforce the
Update-Demand rule over the skills/ surface
docs. None of them can be skipped — a red gate blocks the PR.
Alongside them run the registry-parity gates, which diff a table in Go
against its mirror somewhere else and fail rather than skip when the mirror
is missing: the rule-editor contract tests in rules/ (Go AST ↔ the served
rules-serializer.js) and TestDriverValueMappingTableMatchesTheTypeSwitch in
sqlprovider/, which parses values.go with go/ast and fails if the
driver-value type switch and mappedDriverTypes disagree. Adding a case to one
half and not the other is build-red on purpose. CLAUDE.md carries the full
change → required-update → enforcing-test table.
The book's own links are gated
Two more ordinary Go tests, in internal/docsgate, keep this book honest. They
need no network and no mdBook, so make test already runs them:
go test ./internal/docsgate/
| Gate | Enforces | Trips when you… |
|---|---|---|
TestEveryBookLinkResolves | Every relative link in docs/src names a file that exists and a heading anchor that page actually emits. | Link to a page that moved, or to a #section mdBook does not generate. |
TestEveryBookPageIsInTheSummary | SUMMARY.md and the docs/src tree agree in both directions. | Add a page without listing it (mdBook would not build it), or list a chapter with no file behind it. |
The anchor half is the one worth knowing about, because a wrong anchor is
silent: the file resolves, the browser finds no such id, and the reader lands
at the top of the page as if nothing happened. That is not hypothetical — two
links in this book pointed at #the-acting-principal-principal for an anchor
mdBook never emitted, and they were found by accident.
mdBook derives an anchor by keeping alphanumerics, _ and -, lowercasing
them, turning whitespace into hyphens, and dropping everything else. Dropping
rather than replacing is the part that surprises people, and it never collapses
a run of hyphens:
| Heading | Anchor |
|---|---|
## The acting principal: `--principal` | #the-acting-principal---principal |
# Build, test & lint gates | #build-test--lint-gates |
## The `Filter.Fields` contract | #the-filterfields-contract |
So a heading naming a CLI flag keeps the flag's dashes and gains one more from
the space in front of it. Derive the anchor rather than guessing it, or copy it
out of the built page — make docs writes docs/book, whose <h2 id="…">
attributes are the ground truth the gate's derivation was validated against.
The generated pages are covered like any other. If the gate fires on
reference/cli.md or
reference/error-codes.md, fix the generator
under internal/docsgen and re-run make docs-gen — both files say DO NOT EDIT and mean it. And if the gate fires on a link you believe is fine, fix the
link or fix the heading: there is no exception list, deliberately.
What CI does not gate
The generated reference pages —
docs/src/reference/error-codes.md and
docs/src/reference/cli.md — have no CI drift gate.
Nothing fails if they go stale. Regenerating them is a manual step you own; see
Regenerating artifacts.
Style & testing conventions
Audience: contributors writing or changing Go code in Aperture.
These are the house rules. The authoritative catalog is
CLAUDE.md at the
repo root; this page summarises what a reviewer will check.
Library-first
The product is the public Go packages at the module root. Every surface — the CLI, Twirp/HTTP, MCP — is a thin translator over one decision engine.
- No business logic in
cmd/aperture/.main.goonly assembles the binary and calls intointernal/cli. Decisions, mutations, and policy live in the root packages (engine/,service/,rules/, …). - New capability goes in the library first; the surface then exposes it.
Errors
Every failure that crosses a package boundary is an APERTURE_* coded error
from the root errors/ package.
- Never return bare
errors.New/fmt.Errorfacross package boundaries — wrap in a coded error viaerrors.New/Newf/Wrap/Wrapf. - Codes are SCREAMING_SNAKE,
APERTURE_-prefixed, declared inerrors/codes.go, listed inAllCodes, and each has aRegistryentry with aMessageand at least oneFixup(orFixupNotApplicable: true). - An error already carrying an
APERTURE_*code passes through verbatim — the wrappers never re-stamp it. Recover the code witherrors.CodeOf. - Do not leak cross-account data through error messages. Messages describe the failure, not another tenant's data.
Adding a code is a recipe on the Extending Aperture
page, and is guarded by the TestCodesHaveFixups, TestRegistryHasNoOrphans,
and TestCodesAreScreamingSnakeNamespaced gates.
Pure-Go, no CGO, no Pulse
CGO_ENABLED=0is a hard requirement. Do not introduce a dependency that needs CGO (no geo/h3 or other C-linked packages).- No dependency on Pulse. The rules engine renders its AST to an
expr-lang/exprexpression and compiles it in-process. Aperture usesexpr-lang/exprdirectly; it does not import Pulse. - Storage is hand-written SQL over
modernc.org/sqlite(pure-Go) plus an in-memory implementation behind oneStorageinterface — no ORM, no sqlc, no migration tool.
Naming
- No predecessor references — no
Aperture2,LegacyX, or similar. Name for what a thing is now.
Testing
make testrunsgo test ./.... Tests are table-heavy and co-located with the code they exercise (*_test.go).- The server has
*_smoke_test.gosmoke tests; end-to-end tests live incmd/aperture/(e2e_test.go). - Include tests with new functionality. There are no follow-up-PR commitments for test gaps — cover it in the same PR.
- The performance NFR is asserted by
TestCheckNFRunderbench/, kept out ofmake test; run it explicitly (see gates).
Formatting & lint
Run make fmt, make vet, and make lint before opening a PR. make lint
degrades to go vet when no static analyser is on PATH locally, but CI runs
staticcheck, so fix what it reports.
The Update-Demand rule
Any change to a registered surface must ship the matching skills/*.md
document in the same PR — a surface change without its doc update is a
non-skippable CI failure. This is fully described in
The Update-Demand rule.
Admin UI design system
Audience: contributors changing anything served from internal/server/static/.
Aperture's admin UI shell follows the BERA Design System. These rules are not style preferences — several are load-bearing, and reviewers enforce them. Nothing here is derivable from the code, which is why it lives in the docs.
Voice
An analyst's tool: information density, analytical seriousness, proof-led copy.
Address the reader in second person ("your grants"), never "users". Sentence case
everywhere, with one exception — small form-field labels are uppercase with
0.06em letter-spacing.
Colour
Use named tokens, never raw hex.
- BERA Blue ramp
--bera-25…--bera-900. Primary action is--bera-600, hover--bera-700. - AI-pink
#ff3399is reserved for AI affordances only — the assistant button, an "Ask BERA" pill, AI badges. It is forbidden for primary actions, alerts, and decoration. This one is load-bearing: a pink primary button is a bug, not a taste difference. - Data-visualisation families (grass, strawberry, blueberry, plum, rose, orange, apricot, bera) each carry a 25→900 ramp. A series keeps its colour identity across every chart it appears in. Never invent a chart colour.
- Neutrals: page background
--neutral-200(#f8fafc), card#fff, body text#333333. Pure black is reserved for the wordmark. - Semantic roles:
--color-primary/-success/-warning/-danger/-info,--color-bg/-surface,--color-fg/-muted,--color-border(slate-200),--color-focus-ring(bera-500).
Typography
- Display: BwGradual (commercial). If the
.otfis not shipped, fall back to the system sans — never substitute Inter or Roboto. - Body: IBM Plex Sans.
- Mono: IBM Plex Mono, tabular numerals. Use it for every object-identity
string, entity id, and token-like value —
account:acme/project:atlas/document:42is mono, always. - Scale
--text-2xs…--text-6xl; semantic classes.bera-h1….bera-h4,.bera-body,.bera-label,.bera-numeric.
Spacing, radius, motion
4px base unit; when in doubt take the tighter value (analyst density). Radius sm 4 / md 8 / lg 12 / xl 20 / full. Shadows: card, raised, floating, plus the focus ring. Motion is fade plus a 4–8px translate — no bounces, no springs. Durations: fast 150ms, base 250ms, chart 600ms.
Layout
Dashboard is a fixed left sidebar (~232px) plus a top utility bar (~52px) over flex content. 12-column grid, 8–12px gutters. Modals cap at 720px, or 1080px for heavy workflows.
Iconography
Lucide, stroke-only at 1.5px. 16px inline, 20px default. Always pair an icon with text. AI surfaces may use sparkles / bot / wand-2 in AI-pink.
Hard nos
- No emoji anywhere — including ✅ and 🎯 in confirmations and empty states.
- No exclamation points, no hype words, no "users".
- No invented chart colours.
- No gradients as decoration.
- The primary button is not pink.
Vendored assets, and why there is no build step
Everything the UI needs is committed pre-built under
internal/server/static/vendor/ and //go:embed-ed: Alpine, Tailwind, DaisyUI,
and the Rete.js dist bundle (generated by rete-kit/rete-cli). There is no
node build pipeline in development or CI — the binary ships self-contained, and
CI is node-free by design. Regenerating a vendored bundle is an occasional,
explicit, manual step, never something a build performs.
That node-free property is also why internal/server/static/js/rules-serializer.test.js
never runs in the pipeline, and why rules/editor_js_contract_test.go — which
reads the JavaScript from disk and diffs its tables against the Go rule AST — is
the real parity gate. See Build, test & lint gates.
Regenerating artifacts
Audience: contributors changing error codes, CLI flags, the RPC proto, or the vendored admin-UI bundle.
Aperture keeps three kinds of generated output committed in the repo. None of them is regenerated by CI on the build path, so keeping them fresh is a manual step you own. This page is the checklist for when each one goes stale.
⚠️ There is no CI drift gate on the generated reference docs. The error-code table and CLI reference are regenerated on demand with
make docs-gen. Nothing in CI fails if you forget. If you change an error code or a CLI flag and do not runmake docs-gen, the published book silently goes stale. This is an accepted risk — the mitigation is you, the contributor. Make it a habit.
make docs-gen — generated reference pages
Regenerates the committed reference pages under docs/src/reference/ from the Go
source:
make docs-gen
It emits two files:
| Output | Generated from |
|---|---|
docs/src/reference/error-codes.md | errors.Registry + AllCodes in errors/codes.go |
docs/src/reference/cli.md | the urfave/cli/v3 command tree in internal/cli/ (entry cli.NewApp) |
You must run make docs-gen and commit the result whenever you:
- add, remove, or reword an error code, its
Message, or itsFixups inerrors/codes.go; or - change the CLI — add/remove a command, flag, alias, or usage string in
internal/cli/.
Then rebuild the book to confirm it is clean:
make docs-gen
make docs # mdbook build docs — must succeed with zero errors
Commit the regenerated error-codes.md / cli.md in the same PR as the code
change. Because there is no drift gate, a reviewer cannot rely on CI to catch a
missed regeneration — call it out in your PR description if in doubt.
The RPC reference (
docs/src/reference/ the RPC surface pages) is hand-authored overinternal/wire/rpc/service.proto. There is no generator for it and its drift risk is likewise accepted — update it by hand when the proto changes.
make proto — Twirp / protobuf code
The RPC layer's generated Go (internal/wire/rpc/*.pb.go and *.twirp.go) is
committed; CI does not regenerate it. Rerun the generator yourself after
editing internal/wire/rpc/service.proto:
make proto
Requires protoc, protoc-gen-go, and protoc-gen-twirp on PATH
(paths=source_relative keeps the output beside the .proto). Commit the
regenerated *.pb.go / *.twirp.go alongside the proto change.
make vendor-rete — admin-UI Rete.js bundle
The committed Rete.js bundle at
internal/server/static/vendor/rete/rete.min.js is rebuilt only by:
make vendor-rete
This is the only target that invokes node, and it is a manual,
occasional step — a version bump or plugin change. It is deliberately not a
dependency of build, test, or CI: the normal build ships the committed blob
and never runs node. All npm work happens in a throwaway temp dir, so no
node_modules / lockfile lands in the repo. See
internal/server/static/vendor/rete/README.md and build/rete/build.sh.
Summary
| Target | Regenerates | Run it after you change… | CI drift gate? |
|---|---|---|---|
make docs-gen | docs/src/reference/error-codes.md, docs/src/reference/cli.md | error codes in errors/codes.go; CLI in internal/cli/ | No — manual |
make proto | internal/wire/rpc/*.pb.go, *.twirp.go | internal/wire/rpc/service.proto | No — manual |
make vendor-rete | internal/server/static/vendor/rete/rete.min.js | Rete.js version / plugins | No — manual |
Pull requests
Audience: contributors opening a PR against Aperture.
This page complements the root
CONTRIBUTING.md,
which is the canonical entry point for the mechanics (branching, bug reports).
The pages in this chapter expand on the parts a first-time contributor most often
gets wrong; where they differ in detail, they are the more specific reference for
that topic. Nothing here contradicts the root guide.
The flow
-
Branch off
main. One feature or fix per PR — keep it focused. -
Make your change in the library first (see Style & testing conventions).
-
Add tests in the same PR. New functionality ships with its tests; there are no follow-up-PR commitments for test gaps.
-
Regenerate committed artifacts if you touched their sources — error codes, CLI flags, the RPC proto, or the Rete bundle. See Regenerating artifacts. Remember the reference docs have no CI drift gate, so this is on you.
-
Update the matching
skills/*.mddoc if you changed a registered surface — the Update-Demand rule makes this a non-skippable CI gate. -
Run the local gates:
make fmt make test make vet make lintIf you changed docs, also confirm
make docsbuilds cleanly. -
Commit, push, and open the PR. Describe the change and note any manual regeneration you performed (since CI cannot verify it).
What CI will check
- The full
go test ./...suite viamake test, including the five non-skippable gates:TestCodesHaveFixups,TestRegistryHasNoOrphans,TestCodesAreScreamingSnakeNamespaced,TestUpdateDemandDocPresent, andTestEverySkillHasFrontmatter(see gates). go vetandstaticcheckviamake lint(CI installsstaticcheck).- Not checked: staleness of the generated reference pages. Regenerate them yourself.
Reviewer checklist
A reviewer will look for:
- Business logic in the library, not in
cmd/aperture/. - Cross-boundary errors wrapped as
APERTURE_*coded errors, with no cross-account data in messages. - No CGO, no Pulse dependency, no predecessor-named symbols.
- Tests co-located with the code and covering the new behaviour.
- Regenerated
error-codes.md/cli.mdwhen error codes or CLI flags changed. - The matching
skills/*.mdupdate when a registered surface changed.
Reporting bugs
Follow the root
CONTRIBUTING.md:
include your config (secrets redacted), Go version, OS, and the full APERTURE_*
code of any surfaced error.