Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

OperationQuestion it answers
CheckMay this principal perform this action on this resource?
EnumerateWhich resources/actions is this principal allowed?
ExplainWhy 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=0 end 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 aperture command 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

  1. Installation — build bin/aperture from source (Go 1.26.1, pure-Go, CGO_ENABLED=0).
  2. Concepts primer — principals, actions, objects, patterns and specificity, rules, scopes, and accounts: enough to read any later chapter.
  3. First decision (CLI) — a runnable aperture check walkthrough showing an allow, a default deny, and a deny-override.
  4. 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

RequirementVersionNotes
Go1.26.1The module targets this toolchain; earlier compilers may reject newer language/std usage.
makeanyThe Makefile wraps the build flags; you can also call go build directly.
C compilernoneCGO_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 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:

GrantSubjectEffectObject pattern
g-eng-read-atlasgroup engineeringallow readaccount:acme/project:atlas/**
g-editor-write-atlasrole editorallow writeaccount:acme/project:atlas/**
g-deny-secret-readgroup engineeringdeny readaccount: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 verdict0 for allow, non-zero for deny — so a check composes in a shell pipeline (aperture check … && deploy).

Relevant flags:

FlagDefaultPurpose
--seedembedded examplePath to a JSON/YAML seed model to decide against.
--accountacmeThe active account the decision is scoped to.
--storein-memorySQLite 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):

PackageRole
storage/memoryA backing store for the model. The in-memory implementation is ideal for a demo; storage/sqlite is its persistent twin behind the same interface.
engineThe decision engine. engine.New(store) binds it to a store.
serviceThe facade every surface calls. service.New(engine.New(store)) gives you Check / Enumerate / Explain.
seedLoads 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 Query and Result.
  • 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

FamilyCommandsWhat it does
Decisionscheck, enumerate, explain, identifiersAsk and audit access-control questions (read-only).
Mutationsput, get, list, delete, bestow, revoke, impersonateRead and change the model — entities, grants, delegation, impersonation.
Provisioningtemplate, bulkApply parameterized templates and transactional bulk grant/revoke.
Portabilityexport, importSerialize the whole model to a state file and apply it back.
AttributesattributesInspect the host directories a rule reads principal.* / account.* from, read one, and drop cached bags.
serveserveRun the HTTP + Twirp server and admin UI.
mcpmcpServe 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.

Global options

Audience: operators and integrators driving Aperture from a shell.

aperture declares no persistent global flags. The four options that recur across the command tree — --seed, --store, --account, and --principal — 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:

OptionDefaultMeaning
--seedembedded examplePath to a JSON/YAML seed model to load. When omitted, the committed example fixture is used.
--storein-memorySQLite DSN for a persistent backing store. 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 to disk across invocations. A store built from --store is seeded from --seed (or the embedded example) the first time it is populated.

# Decide against a model file, no persistence:
bin/aperture check alice read account:acme/project:atlas/document:42 \
  --seed ./my-model.yaml

# Persist mutations to a SQLite file so a later command sees them:
bin/aperture put grant --principal root --account acme \
  --store ./aperture.db --file ./grant.json

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), --account defaults to acme (the example account) and bounds which grants the decision considers.
  • On mutation, provisioning, and portability commands, --account has 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.

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 code0 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. 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

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:

FlagSendsNotes
--field key=valuealways a stringRepeatable. Everything after the first = is the value, so --field expr=a=b wants "a=b".
--fields-json '{…}'a JSON object — real numbers, bools, listsUse 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-json is 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 10 gives 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 --via flags 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 --via can never surface an object check would deny.
  • Exactly one hop. The brands a dataset names are not themselves dereferenced, however many references brand declares.

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.

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.

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

Full flags: bestow, revoke.

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.

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:

SubcommandTierWhat it does
template putsystem-adminCreate or update a template (from --json / --file / stdin). Prints put template <name> v<version> ok.
template get <name>noneRead a template as JSON (latest unless --version).
template listnoneList every template version as JSON.
template delete <name>system-adminDelete one --version, or all versions when --version is 0.
template applyaccount-adminInstantiate 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.

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.

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.

SubcommandTierWhat it does
attributes slotsnoneone row per slot: source, ttl, max-size, and how many bags this process has cached
attributes query <slot>system-admina page of that slot's directory as [{id, attributes}], narrowed by attribute predicates
attributes invalidate <slot>system-admindrop 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, and how stale it may be

bin/aperture attributes slots --seed ./seed.yaml
slot     source  ttl    max-size  cached
user     sql     1m0s   10000     12
machine  csv     30s    10000     0
account  inline  never  10000     3

(An unwired slot renders as (unwired) with - in the three columns that describe a cache it does not have.)

  • source is where that slot's bags come from: csv or sql (an attribute_providers: entry), inline (the attributes: block), (host) for a registry a host wired in Go rather than from the seed, and (unwired) for a slot this deployment declares no source for. An unwired slot is not an error: every decision for it resolves the floor bag and proceeds.
  • ttl is the revocation window. never means a fetched bag is dropped only by eviction or by an explicit invalidate — correct for a fixed inline block, dangerous for a live directory.
  • cached counts this process. A one-shot invocation starts cold and reads 0; it is the number that matters in a long-running serve.

slots needs no actor. It discloses nothing the caller did not already supply: it reads the seed file named on the command line plus the cache configuration this process built from 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.

When a seed declares one slot in both attribute_providers: and attributes:, every command that builds the stack prints a warning naming the affected slots — the external entry wins and the inline bags for that slot are discarded entirely. Only slot names are named, never keys.

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.

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.

  • 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: and attribute_providers: blocks these commands report on.
  • Rules engine — what a rule reads when a slot answers nothing.
  • Command-Line Reference — the generated 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 --seed or the embedded example.
  • --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, or parsec. It overrides the APERTURE_AUTH_MODE env var. Because the default is dev, serve runs with no external identity provider out of the box; oidc and parsec are 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 via APERTURE_ENFORCE_MEMBERSHIP.

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 declared in the seed's providers: section. A rule saved through the admin UI takes effect on the next decision with no separate rule store.

Full flags: serve.

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.

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:

PackageTypeRole
engine*engine.EngineThe 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.ServiceThe 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:

OperationQuestion it answersSingleBulkImpersonated
CheckMay this principal take this action on this one object?CheckCheckBatchCheckAs
EnumerateWhich objects under a pattern may this principal act on?EnumerateEnumerateBatchEnumerateAs
ExplainWhy was this decision reached — which grants and at what specificity?ExplainExplainBatchExplainAs

The pages that follow cover each cluster:

  • Decision API — the single Check / Enumerate / Explain operations: real signatures, input and result shapes, and when to reach for each.
  • Batch operationsCheckBatch / EnumerateBatch / ExplainBatch and the generic BatchResult[T] type that keeps one bad query from failing a whole batch.
  • ImpersonationCheckAs / EnumerateAs / ExplainAs and the ImpersonationContext decorator 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 / SimulateExplain what-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:

OptionEffect
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().
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 — today only a skipped dangling reference, which is invisible to an operator otherwise. 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 raw engine.Check here 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 default 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 default) is clamped to engine.DefaultEnumerateLimit (1000), so an enumeration can never materialise an unbounded set. 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.

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:

  1. 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.
  2. Filter before Limit. The candidate set is predicated before it is truncated, so asking for the first 10 objects tagged brand:Y searches 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 the enumeration already warmed:

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-typeAPERTURE_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_FOUND from Fetch) → 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:

SituationResult
The principal may not read the holderEmpty 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 memberAPERTURE_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 AccountEmpty, whether or not it exists. The disclosure boundary.
The caller is not a memberEmpty, always — membership is decided before the holder is looked up.
A referenced identity no longer existsSkipped, 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 wiredAPERTURE_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 targetAPERTURE_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.

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:

  1. Diagnostic only — a note never influences a verdict.
  2. Explain onlyCheck and Enumerate install no collector, so they record nothing, allocate nothing, and behave exactly as before.
  3. 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

QuestionOperation
"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
"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. 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.

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 reqsresult[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.

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
)
  • ModeAugment resolves 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.
  • ModeBecome resolves 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 the impersonation package). The audit trail still records the real operator.
  • ModeNone is the inert default: a zero ImpersonationContext confers no elevation, and the *As operation 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 as APERTURE_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 become the rule's principal.id is the target's id and the target's kind picks the attribute slot principal.* is read from; under augment both 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 Under become, 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/ExplainAs return a deny with no deciding grant; EnumerateAs returns the empty set.)
  • The returned Decision / Trace carries ic on its Impersonation field for audit. In a Trace, Subjects is the effective subject set while Request.Principal remains 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 setprincipal.id / principal.*Request.PrincipalImpersonation.RealActor
augmentoperator ∪ targetoperatoroperatoroperator
becometarget alonetargetoperatoroperator

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.

  • Decision API — the plain operations these mirror.
  • The service facade — surfaces reach impersonation through the facade's impersonation service (wired with WithImpersonation).
  • The impersonation package — 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:

OptionEnables
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.
WithRuleSource(base rules.RuleSource, fetcher rules.MetadataFetcher)The what-if preview of an unsaved rule via Simulate's Overlay.Rules.
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 nil want.
  • Comparison is typed. int(5) and float64(5) are one value; "5" is not 5.
  • The filter runs before Limit. Candidates are decided, then filtered, then truncated — so "the first 10 objects tagged brand: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 Check would deny.

Failure is asymmetric on purpose, because a short answer reads as "no access":

SituationResult
No metadata source wired, or no provider for the candidate's object-typeAPERTURE_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 failureSurfaced 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, Fields rides as map<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 as APERTURE_INVALID_INPUT. See the RPC reference.
  • Fields carries json and jsonschema struct tags — the only tagged field on any service type. The MCP surface aliases this struct (mcp.EnumerateIn) and reflects its tool schema off it, so the omitempty is 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.

  • HolderType is optional. Empty means "whatever HolderID's terminal segment type is"; when given it must agree with HolderID, and the engine — not this type — enforces that.
  • Several edges AND, an edge composes with Fields, and both precede Limit.
  • 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 EnumerateAs checks the holder with the impersonated authority, not the operator's.

The failure modes are asymmetric on purpose, and the asymmetry is the security model:

SituationResult
The principal may not read the holderEmpty 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 memberAPERTURE_NOT_FOUND — the ergonomics a typo deserves, confined to a caller already inside the account.
The holder is outside AccountEmpty, whether or not it exists. This is the disclosure boundary.
The caller is not a member of AccountEmpty, always — membership is decided before the holder is looked up.
A referenced identity no longer existsSkipped, with a warning log and a dangling_reference note; never a failed decision.
The field is not a declared referenceAPERTURE_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 sourceAPERTURE_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.

Fail-closed rendering

The facade's reason for existing is one shared policy for turning an engine outcome into a rendered decision:

Engine outcomeFacade renders it as
A clean decisionPasses 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.

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 at Now), and Notes (the evaluation's deny-safe observations). EvaluateRule is 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 — a rules.Input with a zero Now has 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.tier in the preview sees nothing, and no attributes_floor_only note 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. Explain takes 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,
})
MethodDoes
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.

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/json for JSON bodies (the form shown throughout these docs) or application/protobuf for the binary form. Both are the standard Twirp codecs; the JSON encoding is identical to the library's own JSON.
  • HTTP status is always 200 for 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:

RoutePurpose
POST /checkThe 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 /healthzLiveness 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 401 with 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 RPCRequirement
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 codeHTTP
APERTURE_INVALID_INPUT, APERTURE_RULE_INVALID, APERTURE_TEMPLATE_PARAM, …invalid_argument400
APERTURE_UNAUTHENTICATED, APERTURE_INVALID_TOKENunauthenticated401
APERTURE_AUTHZ_DENIED, APERTURE_DELEGATION_DENIED, APERTURE_IMPERSONATION_DENIED, …permission_denied403
APERTURE_NOT_FOUND, APERTURE_RULE_NOT_FOUND, APERTURE_PROVIDER_UNREGISTEREDnot_found404
APERTURE_ENTITY_UNMANAGED, APERTURE_STORAGE_CONSTRAINTfailed_precondition412
APERTURE_UNIMPLEMENTEDunimplemented501
anything elseinternal500

See Error Codes for the full registry.

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\"]}"}'
{}

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.

RPCRequest → ResponsePurpose
CheckCheckRequestDecisionIs principal allowed action on object in account? Returns allow, a reason, and the deciding grant ids.
CheckBatchCheckBatchRequestCheckBatchResponseMany Checks in one call; results are index-aligned, each either a Decision or a per-item error code+message.
EnumerateEnumerateRequestEnumerateResponseWhich object ids matching pattern may principal take action on? Optional fields (an object-metadata filter, below), optional references (reference edges, below) and limit.
EnumerateBatchEnumerateBatchRequestEnumerateBatchResponseBatched Enumerate; index-aligned results. Each query embeds an EnumerateRequest, so fields and references are per-query.
ExplainCheckRequestExplainResponseThe full decision derivation for a query, as trace_json (the recursive engine Trace, not modelled in proto).
ExplainBatchCheckBatchRequestExplainBatchResponseBatched 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"] }

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:

RuleMeaning
AND across keysEvery predicate must hold.
Membership on collectionsA 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 matchesAn object whose metadata lacks the key is excluded — not even a null want matches it.
Typed comparisonNumbers 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 limitCandidates 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 subtractsThe 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.Value carries 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 of Value itself. (internal/wire/rpc's TestFieldsRoundTrip_LargeIntegerLosesPrecision pins 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-finite Value at all, so only the binary codec can reach this guard;
  • a Value with 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.

RuleMeaning
Several edges AND"the brands in dataset X and in campaign Z".
Composes with fieldsBoth apply; they are independent.
Before limitRestriction → decision → filter → truncate.
Only subtractsEdges apply to candidates that already survived deny-overrides, so no edge can surface an object Check would deny.
Exactly one hopThe identities an edge yields are never themselves dereferenced.
holder_type is optionalEmpty 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:

SituationResponse
the principal may not read the holderHTTP 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 memberAPERTURE_NOT_FOUND / 404 — the ergonomics a typo deserves, confined to a caller already inside the account.
the holder is outside accountempty, 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 memberempty, always — membership is decided before the holder is looked up, so a non-member never sees a 404.
a referenced identity no longer existsskipped, 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 referenceAPERTURE_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 providerAPERTURE_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_INVALID is 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 in meta["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.

EntityPut (system)Get (auth)List (auth)Delete (system)
Object typePutObjectTypeGetObjectTypeListObjectTypesDeleteObjectType
PermissionPutPermissionGetPermissionListPermissionsDeletePermission
PrincipalPutPrincipalGetPrincipalListPrincipals¹DeletePrincipal
RolePutRoleGetRoleListRolesDeleteRole
GroupPutGroupGetGroupListGroupsDeleteGroup
AccountPutAccountGetAccountListAccounts¹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 (ObjectIdentifiersRequestObjectIdentifiersResponse, 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.

RPCRequest → ResponseAuthPurpose
PutGrantEntityRequestEmptyaccountCreate/replace one grant (model.Grant as entity_json).
GetGrantGetRequestEntityResponseauthRead one grant, visibility-scoped to the caller.
ListGrantsListGrantsRequestEntityListResponseauthGrants in account_id, with optional Filter.
DeleteGrantDeleteRequestEmptyaccountDelete one grant by id.
PutMembershipEntityRequestEmptyaccountAdd/replace a principal's membership in an account.
DeleteMembershipMembershipKeyRequestEmptyaccountRemove 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.

RPCRequest → ResponseAuthPurpose
PutRuleRuleRequestEmptysystemPersist a rule definition.
GetRuleGetRequestRuleResponseauthRead one rule as rule_json.
ListRulesEmptyRuleListResponseauthEvery stored rule, each as JSON.
DeleteRuleDeleteRequestEmptysystemDelete a rule by id.
ValidateRuleRuleRequestEmptyauthCompile/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.

RPCRequest → ResponsePurpose
SimulateSimulateRequestDecisionThe 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.
SimulateExplainSimulateRequestExplainResponseSame overlay, returning the full Explain trace.
EvaluateRuleEvaluateRuleRequestEvaluateRuleResponseRun 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).

RPCRequest → ResponseAuthPurpose
PutTemplateEntityRequestEmptysystemPersist a template definition.
GetTemplateTemplateKeyRequestEntityResponseauthRead template (name, version); version <= 0 selects the latest.
ListTemplatesListRequestEntityListResponseauthList templates, with optional Filter.
DeleteTemplateTemplateKeyRequestEmptysystemDelete by name/version (version <= 0 deletes all versions).
ApplyTemplateApplyTemplateRequestEntityListResponseaccountExpand 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.

RPCRequest → ResponsePurpose
BulkPutGrantsBulkGrantsRequestEmptyCreate/replace many grants (grants_json) in one transaction.
BulkDeleteGrantsBulkDeleteGrantsRequestEmptyDelete many grants by id in one transaction.

Declarative state (system-tier)

Whole-model portability.

RPCRequest → ResponsePurpose
ExportExportRequestExportResponseSerialize the entire model to one declarative state file as document_json (a system-tier read).
ImportImportRequestEmptyApply a state file (document_json) as an idempotent, transactional upsert (the most privileged mutation; system-tier).

Audit query (gated read)

RPCRequest → ResponsePurpose
QueryAuditQueryAuditRequestQueryAuditResponseThe 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.

Delegation (own rule)

Not admin-gated; authorized by the delegation subset rule, with the actor = the authenticated delegator.

RPCRequest → ResponsePurpose
BestowBestowRequestEmptyHand on a subset of the delegator's own grants (grant_json).
RevokeRevokeRequestEmptyRevoke 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.

RPCRequest → ResponsePurpose
ImpersonationStartImpersonationStartRequestImpersonationSessionBegin impersonating target in account under mode (augment or become); returns the session with started_at / expires_at (RFC3339).
ImpersonationStopImpersonationStopRequestEmptyDiscard a session (client-side; echoed for symmetry/audit).

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)

ToolPurposeMaps to
aperture_checkDecide 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_batchDecide 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_enumerateList 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_batchEnumerate 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_explainReturn 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_batchReturn 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_FOUND only 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)

ToolPurposeMaps to
aperture_simulateRender 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

ToolPurposeMaps to
aperture_list_object_typesList every object type with its declared action verb set.service.ListObjectTypes
aperture_get_object_typeFetch one object type by name.service.GetObjectType
aperture_list_permissionsList every permission (object-type, action, scope-strategy, delegatable flag).service.ListPermissions
aperture_get_permissionFetch one permission by id.service.GetPermission
aperture_list_rolesList every role (named permission bundles).service.ListRoles
aperture_get_roleFetch one role by id, including its permission bundle.service.GetRole
aperture_list_groupsList every group (collections of principals usable as grant subjects).service.ListGroups
aperture_get_groupFetch one group by id, including member principal ids.service.GetGroup
aperture_list_principalsList every principal (user or machine) with assigned role ids and identity strings.service.ListPrincipals
aperture_get_principalFetch one principal by id, including assigned roles.service.GetPrincipal
aperture_list_grantsList every grant stamped to an account. Account-scoped: a grant in another account is never returned.service.ListGrants
aperture_get_grantFetch one grant by id (subject, permission, object pattern, effect, account).service.GetGrant

Surface documentation

ToolPurposeMaps to
aperture_skills_listList the embedded skill docs describing how the decision, simulate, and inspection tools fit together.mcp/skills.List
aperture_skills_getFetch 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.

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 id on a get_* 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 error field (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:

FileRole
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.goThe typed In/Out structs for every tool (aliasing the facade's service / engine / model types).
schema.goReflects an input + output JSON Schema for each contract type at init, carried as json.RawMessage.
handlers.goOne typed func(ctx, *service.Service, In) (Out, error) per tool; each calls exactly one facade read/decision method.
tools.goType-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_NoSDKImport runs go list -deps over 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_AllowedDepsReachable asserts 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 (defaults to the embedded example).
  • --store — sqlite 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.

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.

LayerRole in the shell
Alpine.jsSmall reactive framework driving each screen's component and the hash-routed navigation.
Design tokensThe 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 / DaisyUIVendored utility/component CSS carried for the domain screens.
Rete.jsA 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 the serve command 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.

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 an APERTURE_IDENTITY_INVALID coded 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:

FormMeaning
type:ida literal segment — matches exactly one segment with that type and id
type:* / *:ida 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:

SignalWeightRationale
literal components (a fixed type or id)10000 eacha pinned value is the strongest specificity signal
total segment count100 eacha longer pinned path breaks ties among equal literal counts
each **−1000 penaltya 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/expr directly. 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 imports github.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.

NodeTypeFields usedMeaning
andChildren (≥ 2)logical conjunction
orChildren (≥ 2)logical disjunction
notChildren (exactly 1)logical negation
compareOp, Left, Righta comparison; Right is omitted for the unary operators
varName (dotted path)a context-variable reference
literalValue (scalar JSON)a string, number, bool, or null constant
listItemsan ordered list, the right side of a collection operator
callName, 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:

RootTypeContents
objectmapthe object's metadata snapshot (host-defined fields, e.g. object.classification)
principalmapthe principal's attribute bag (e.g. principal.tier); the floor {id, kind} is always present
accountmapthe active account's attribute bag (e.g. account.plan); the floor {id} is always present
actionstringthe 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

OpReads asRenders to
eq / neobject.tier == "gold"== / !=
lt le gt geobject.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.

OpReads asLeft applies toRight
inobject.region in ["us","eu"]anya list or a var
ninprincipal.id not in object.blocklistanya list or a var
hasobject.tags has "urgent"arrayone element — never a list
hasAllobject.tags has all ["a","b"]arraya list or a var
hasAnyobject.tags has any ["a","b"]arraya list or a var
hasNoneobject.tags has none ["a","b"]arraya list or a var
subsetOfobject.tags subset of ["a","b"]arraya list or a var
hasKeyobject.owner has key "dept"objectone element — never a list
isEmptyobject.tags is emptyarray, objectomitted
isNotEmptyobject.tags is not emptyarray, objectomitted
existsobject.owner.dept existsany pathomitted
// "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:

StrategyOperatorsRendered form
Native infixeq ne lt le gt ge in nin(left <op> right)
Native, flipped inhas, hasKey(right in left)
Native nil testexists(left != nil)
Registered pure functionhasAll hasAny hasNone subsetOf isEmpty isNotEmptyhasAll(left, right), isEmpty(left), …
Guarded dispatcherany 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:

ExpressionObject has the fieldObject lacks the field
x in object.blocklistmembershipfalse — does not select
x not in object.blocklistnon-membershiptrueselects

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 absenthashasAllhasAnyhasKeyisNotEmptyexistshasNonesubsetOfisEmpty
resultfalsefalsefalsefalsefalsefalse⚠️ 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 shapehas 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:

operandpolicy
absent (nil)reads as an empty collection; the operator's own semantics decide, so the negative ops match
wrong shapethe 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:

operatorsaccepts
in nin has hasKeyarray (elements) or object (keys)
hasAll hasAny hasNone subsetOfarray
isEmpty isNotEmptyarray, object, or string
existsanything — 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 errorAPERTURE_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 list node for collections).
  • isEmpty / isNotEmpty / exists carry no right operand; every other operator carries one.
  • in nin hasAll hasAny hasNone subsetOf take a list or a var on the right.
  • has / hasKey take a single element on the right — a list there is an error that points at hasAll/hasAny/hasNone.
  • A call may 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; Validate denies those by name.
  • The curated pure function setlower, upper, contains, startsWith, endsWith, len, plus the collection-operator backings hasAll, hasAny, hasNone, subsetOf, isEmpty, isNotEmpty. A host adds its own deterministic, side-effect-free functions with rules.Function(name, fn) / Engine WithFunction; these join (and can shadow) the curated set. An unknown function is caught at compile time. Note that contains, startsWith and endsWith are reserved as infix operators by expr's grammar, so they are registered but cannot be reached through a call node; 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 InputObject, 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 elseaccount.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:

  1. resolve the rule reference through the RuleSource;
  2. compile-and-cache its AST;
  3. fetch the object's metadata (empty when no fetcher is configured);
  4. 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);
  5. resolve the active account's attributes the same way, through the same read-only contract, and stamp {id} over them into a fresh map;
  6. build the Input and 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

StrategyMembership within the pattern scope
literalexactly the objects the pattern matches (the baseline)
implicitevery object of the permission's type in scope — unfettered
inclusiveopt-in: only an explicit id-list, or objects a rule selects
exclusiveopt-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 (capped by DefaultMaxMembers = 1000) for Enumerate-style callers. 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:

SeamSupplied byDefault behaviour
ObjectListerthe provider Registry (*Registry matches its signature byte-for-byte)APERTURE_SCOPE_LISTER_UNCONFIGURED
RuleEvaluatorthe 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)
}
  • Fetch returns one object's metadata; a missing object yields an APERTURE_NOT_FOUND coded error, so the Registry can tell "absent" from an operational failure.
  • List is the unfiltered enumeration of the type.
  • Query returns the objects matching a Filter — an optional Pattern (bounds results to matching identities), a set of Fields predicates, and a Limit. The zero Filter selects everything (equivalent to List).

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 valueWantRule
["premium","launch"]"premium"membership — true
["premium-trial"]"premium"membership — false, never substring
[3, 5] (int64)5membership — 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 includedfalse

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, so Enumerate cannot select an object that a Check over the same value then denies. provider.ValuesEqual is the exported leaf comparison, and csvprovider's membership_equivalence_test.go runs 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 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:

KindGo typeExample
scalarnil, bool, string, any Go integer/float, json.Numberclassification: "secret"
array[]any whose elements are all scalarstags: ["eng", "oncall"]
objectmap[string]any of scalars, scalar arrays, or one further object levelowner: {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:

FormLayoutExample
calendar day2006-01-022026-03-04
timestamp2006-01-02T15:04:05Z2026-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.

ValueDepthLegal?
tags: ["a", "b"]1yes
owner: {dept: "eng"}1yes
owner: {lead: {name: "x"}}2yes
owner: {tags: ["a"]}2yes
owner: {a: {b: {c: "x"}}}3no — past the depth cap
owner: {members: [{id: 1}]}3no — array of objects
tags: [{name: "a"}]2no — 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 Metadata it 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:

  • Fetch is a rules.MetadataFetcher — its signature is exactly what the rules Engine wants for object metadata, so a *Registry is wired in as the fetcher directly.
  • List(ctx, objectType, pattern, limit) is a scope.ObjectLister — byte-for-byte the seam the implicit/exclusive scope resolvers left open, so a *Registry is passed as engine.ScopeDeps{Lister: reg}. It queries the provider, bounds the result by the pattern and the limit (DefaultListLimit = 1000), and opportunistically warms the cache with each returned object's metadata.

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.

Cache tuning and invalidation

Each type's cache is an in-memory LRU (MemoryCache) behind the pluggable CacheBackend interface, tuned per type at registration:

OptionDefaultEffect
WithTTL(d)DefaultTTL = 30sfreshness window; d ≤ 0 disables expiry
WithMaxSize(n)DefaultMaxSize = 10 000LRU cap; n ≤ 0 means unbounded
WithClock(now)time.Nowinjectable 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:

SlotConstantKeyed byBacks
userAttributeSlotUsera bare principal idprincipal.* for a human principal
machineAttributeSlotMachinea bare principal idprincipal.* for a service account, API client, or job runner
accountAttributeSlotAccounta bare account idaccount.* 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 per-slot cache, 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)

Registering a slot twice is 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 left unregistered is not an error — a deployment with no machine principals wires no machine provider.

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. 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 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.

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.

Where the bags come from

ImplementationSourceNotes
provider.StaticAttributesan in-memory sliceimmutable after construction; everything validated up front, so a read can never fail for a reason wiring could have reported
csvprovider.NewAttributes(path)one CSV filethe 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 Querierthe 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.

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, because brand has 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 ordinary Filter.Fields contract 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.

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_FOUND only inside the request's account and only for a member — outside the account, or for a non-member, the answer is empty and never NOT_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.

SuffixCellsStored as
:datea calendar day2006-01-02
:datetimean instant2006-01-02T15:04:05Z

Four rules, each because the alternative is a silently wrong answer:

  • The declared type fixes the granularity. A :date column rejects a timestamp and a :datetime column 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:00 means January 1st to whoever wrote it, and its UTC instant is 2025-12-31T19:00:00Z — converting silently moves the year. A Z suffix is accepted, and so is an offset-free timestamp (read as UTC).
  • An empty cell omits the field, following the scalar rule (row brand:2 above has neither). An absent date differs meaningfully from any date, and a zero time would silently satisfy every before rule written against the column.
  • A date-shaped string in a :json cell is not date-validated. :json is 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
SuffixElements
:liststrings, 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.

RuleWrite
An array must be cast to JSON — the only way a list-valued field arrives as a listto_jsonb(tags) AS tags
A day-granular date must be cast to text — every time.Time becomes the datetime formhired_on::text AS hired_on
A numeric must be cast::float8 if it is a number, ::text if it is an identifieramount::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 typeMetadata value
nil (SQL NULL)the field is omitted — the same absent-vs-zero rule as an empty CSV cell
bool / int64 / float64 / stringthe scalar, as-is
[]byteJSON-decoded — how arrays and nested objects arrive
time.Time.UTC(), then the canonical 2006-01-02T15:04:05Z
anything elseAPERTURE_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.

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.WithTimeoutDefaultTimeout 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

EntityWhat it is
ObjectTypeA protected resource type (e.g. document) with a declared, closed set of action verbs.
PermissionAn (action, scope-strategy) pair bound to one object type; optionally Delegatable.
PrincipalA user or machine, addressable by the identity scheme; carries its assigned RoleIDs.
RoleA named bundle of permissions; a principal is assigned roles, and a role may also be a grant subject.
GroupA collection of principals that can itself hold grants (be a grant subject).
AccountA first-class tenancy boundary: the unit a grant is stamped to and the context a decision is scoped to.
MembershipThe edge linking a global principal to an account it belongs to.
GrantBinds a subject to a permission, scoped to an object pattern and an effect, stamped to an account.
RuleA named, persisted rule AST (see Rules) a scope strategy can reference.
TemplateA 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 with identity.ParsePattern.
  • Effectallow or deny. 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"]
  1. 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).
  2. May-delegate — the delegator holds an effective allow grant on aperture.delegate whose object pattern covers G's object.
  3. DelegatableG's underlying permission is flagged Delegatable. This is the model's opt-in gate; a permission is non-delegatable until explicitly flagged.
  4. Subset — the delegator holds an effective allow grant with the same action and scope strategy as G's permission, whose object pattern covers G's object. In other words, G must 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.

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:

ModeReserved verbConstant
Augmentaperture.impersonate.augmentimpersonation.AugmentAction
Becomeaperture.impersonate.becomeimpersonation.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.become covering the target.
  • Augment requires an allow on aperture.impersonate.augment or aperture.impersonate.become covering 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:

  1. Account boundaryboth 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.)
  2. 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 DefaultTTL15 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 returns APERTURE_IMPERSONATION_EXPIRED once 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.

  • Library: Impersonation — the *As engine API and the ImpersonationContext decorator 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 roles array matches roles contains admin when 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":

OpConstantMatches when the field…
eqOpEqequals the value (case-insensitive)
containsOpContainscontains the value as a substring (case-insensitive)
startsOpStartsstarts with the value as a prefix (case-insensitive)
emptyOpEmptyis 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.

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 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's keyring.json directly.
  • StateDir — the broker's state directory, inside which keyring.json is resolved (the Orbit serve.go shape, 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.go adapter exposes — the keyring-verification seam. The broker's own token-minting protocol lives in the parsec module, 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 varConfig fieldApplies toNotes
APERTURE_AUTH_MODEModealldev | oidc | parsec; empty ⇒ dev
APERTURE_AUTH_PRINCIPAL_CLAIMPrincipalClaimoidc, parsecempty ⇒ sub
APERTURE_OIDC_ISSUEROIDCIssueroidcrequired (issuer URL + discovery root)
APERTURE_OIDC_AUDIENCEOIDCAudienceoidcrequired (Aperture's client id at the IdP)
APERTURE_OIDC_JWKS_URLOIDCJWKSURLoidcoptional; set to skip discovery
APERTURE_PARSEC_KEYRINGParsecKeyringPathparsecpath to keyring.json (wins over state dir)
APERTURE_PARSEC_STATE_DIRParsecStateDirparsecbroker 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
  • 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 codesAPERTURE_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.

MethodDisciplineNotes
Record(ctx, ev)always, synchronousstamps id + timestamp, writes through the sink, returns the storage error
RecordDecision(ctx, fn)sampled, asynchronousinvokes 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:

OptionEffect
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:

FieldMeaning
ID, Timestampassigned by the recorder
EventTypebroad category the query filters on: mutation, decision, impersonation, delegation
Actionthe specific operation, e.g. PutGrant, Check, Bestow, ImpersonationStart
Actorthe principal that really acted
EffectiveSubjectthe target whose authority was borrowed under impersonation (empty otherwise)
ImpersonationModeaugment or become under an impersonation session (empty otherwise)
Accountthe active account the event was scoped to
Targetthe entity, object, or resource the event concerns
Outcomeallow/deny for a decision, success/failure for a mutation/impersonation/delegation
Reasonhuman-readable explanation (deciding grants, or the failure cause)
Detailsoptional 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 an AuditFilter (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.

  • 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 Storage backend 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

TierAuthority anchorGovernsHolder
Systemsystem: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 **)
Accountaccount:<acct>/admin:all (spelled account:<acct>/admin:*)grants and delegation within one account onlyanyone 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:* and account:<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 on aperture.admin over **, 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 their delete_* pairs), plus import — applying a whole declarative state file, the most privileged mutation there is.
  • Account tier: put_grant / delete_grant, bestow / revoke (delegation), and put_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")
MethodReturns nil when…Otherwise
Authorize(ctx, actor, m, account)the actor holds the tier m requiresAPERTURE_AUTHZ_DENIED; unknown mutation ⇒ APERTURE_INVALID_INPUT
RequireSystemAdmin(ctx, actor)the actor holds system-admin authorityAPERTURE_AUTHZ_DENIED
RequireAccountAdmin(ctx, actor, account)the actor holds account-admin in the target accountAPERTURE_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.

  • The decision engine — the Check/Explain every authority question resolves through.
  • Delegation ("bestow") — the bestow/revoke mutations this gate guards, and the aperture.delegate idiom it mirrors.
  • Impersonation — the aperture.impersonate.* idiom.
  • Audit trail — where a gated mutation's outcome is recorded.
  • Error codesAPERTURE_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 — runtime wiring (see below)
providers:     [ ... ]   # runtime wiring, not model state (see below)
objects:       [ ... ]   # inline object metadata — also wiring, not model state
field_types:   [ ... ]   # declared types for inline metadata fields — also wiring
attributes:    [ ... ]   # inline SUBJECT attributes (principal / account) — also wiring
attribute_providers: [ ... ]  # EXTERNAL sources for those same bags — also wiring

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.

Apply is 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 use Storage.Atomic when 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, so Export queries * 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 segmentaccount: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, so hired_at: 2026-03-04 arrives already widened to 2026-03-04T00:00:00Z and a field declared date rejects 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 every before rule 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 a providers: 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. A providers: entry carries its own typing (the CSV :date / :datetime suffix). 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:, no default:, no enum:, no pattern:, no int/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 — csv or sql alike — wins and every inline objects: 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 typesbefore 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 keyMeaning
connectionthe connections: entry to read through. Required for kind: sql; a name with no matching entry is a hard error at build.
get_onethe "get one" statement, taking exactly one placeholder, to which the identity's terminal segment value is bound
get_allthe "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_columnthe get_all result column holding the identity. Default id.
referencesdeclared object references: field name → target object-type. See Declaring a reference. Works on any kind.
ttl / max_sizethe 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.

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.

KeyDefaultNotes
query_timeout5sbounds one statement. Must be positive — there is no "no timeout" setting, because an unbounded statement under Check is an unbounded decision.
max_open_conns10deliberately not database/sql's unlimited default; negative restores it. An unbounded pool lets a burst of Checks exhaust the host's server.
max_idle_conns5zero 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_lifetime30m"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: brand has 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. 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 slotsuser 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 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: — and note that when both sections claim one slot, the external entry wins it outright.

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. 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.

dsn: is refused by name wherever it appears, here as on a providers: entry: credentials belong to a connections: entry's dsn_env:.

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: the external source wins, entirely

When both sections declare the same slot, the attribute_providers: entry wins and every inline attributes: entry for that slot is discarded entirely. There is no per-subject merge and no fallback: an inline id the external source happens to lack is simply not resolvable, exactly as if the entry had never been written. It is the providers: / objects: rule at slot granularity, and for the same reason — field-level merging is the most useful-sounding behaviour and the most impossible to debug, because a rule reading a department the directory silently did not override is a support ticket nobody can reproduce.

The discard is not silent. Document.AttributeCollisions() reports the affected slots and the caller surfaces them (aperture prints a warning). 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" — so a surface that displays the wiring reads the precedence rule instead of re-deriving it and eventually disagreeing with it.

What is not in the file

Two 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 Export reads 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: Apply writes no row for attributes: or attribute_providers: and an export reproduces none of either.
  • Runtime wiring — the connections:, providers:, objects:, field_types:, attributes: and attribute_providers: sections are runtime wiring, not model state. Apply never writes any of them to storage; instead Document.BuildRegistry(baseDir) — or BuildRegistryWithConnections when the document declares connections: — turns the first four into a live *provider.Registry, and Document.BuildAttributeRegistry(baseDir) — or BuildAttributeRegistryWithConnections when an attribute source is kind: sql — turns the last two into a live *provider.AttributeRegistry. The seed file is the source of truth for them, exactly as auth config is — and an export reproduces none of them. A declared provider names an object_type, a kind (csv or sql), optional cache ttl/max_size, and then either a path (for csv, resolved relative to the seed file) or a connection plus get_one / get_all statements (for sql — see Database-backed providers), plus an optional references: block (see Declaring a reference). A malformed entry is APERTURE_CONFIG_INVALID / APERTURE_PROVIDER_INVALID, and a broken connection declaration is APERTURE_SQL_PROVIDER_CONNECTION or APERTURE_SQL_PROVIDER_DSN_LITERAL. When providers: and objects: claim one type, providers: wins the type outright — see When both sections claim a type.
  • The RBAC model — the entities the document mirrors.
  • Rules engine — the canonical AST a rule's ast field carries.
  • Providers — the registry providers: wiring builds, and the cache that is never exported.
  • Storage — the Storage backend Apply writes through and Export reads back.
  • Portability CLI — the command surface over import/export.

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 Registry in errors/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:

ConstructorUse
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) Codethe 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. CodeOf recovers the existing code; the wrappers do not overwrite it. This lets a lower layer set the precise code (e.g. a provider returning APERTURE_NOT_FOUND for 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) or FixupNotApplicable = true when 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:

GateEnforces
TestCodesHaveFixupsevery code has a Registry entry with a Message and a Fixup (or FixupNotApplicable)
TestRegistryHasNoOrphansRegistry contains nothing absent from AllCodes
TestCodesAreScreamingSnakeNamespacedevery 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.

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 on modernc.org/sqlite, a pure-Go driver, so CGO_ENABLED=0 holds end to end.
  • storage/postgres — a full peer of the SQLite backend on jackc/pgx/v5/stdlib through database/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.sql14 tables and 7 indexes, no ORM, no sqlc, no migration tool.

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:

OperationContract
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 failuresurfaces 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.

PropertyValue
Unset0 — 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 window1677-09-21T00:12:43.145224192Z .. 2262-04-11T23:47:16.854775807Z
Outside that windowrefused with APERTURE_INVALID_INPUT before the write — never wrapped, clamped, or stored as an overflow value
Round tripnanosecond-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

Nine relationship columns carry a real foreign key, identically in both SQL dialects:

Child columnParentON DELETE
apt_memberships.principal_idapt_principals(id)RESTRICT
apt_permissions.object_typeapt_object_types(name)RESTRICT
apt_principal_roles.principal_idapt_principals(id)CASCADE
apt_principal_roles.role_idapt_roles(id)RESTRICT
apt_role_permissions.role_idapt_roles(id)CASCADE
apt_role_permissions.permission_idapt_permissions(id)RESTRICT
apt_group_members.group_idapt_groups(id)CASCADE
apt_group_members.principal_idapt_principals(id)RESTRICT
apt_grants.permission_idapt_permissions(id)RESTRICT

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 join rows — a principal's role list, a role's permission list, a group's member list. The join 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_id and apt_grants.account_id legitimately hold model.AccountWildcard ("*"), and "*" is deliberately not an account row — ValidateAccount refuses 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 "an apt_accounts row or exactly "*"".
  • apt_grants.(subject_kind, subject_id) is polymorphic: the kind selects whether the id must exist in apt_principals, apt_roles, or apt_groups.

Columns that deliberately carry no foreign key

This list is load-bearing. Adding a key to any of these breaks something.

  • apt_audit_log carries no foreign keys at all. actor, effective_subject, account and target name 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 columnsapt_object_types.apt_actions and apt_templates.apt_grants — are value lists, not relationships. apt_templates.apt_grants is spelled that way because grants is a reserved word, not because it references the apt_grants table.
  • 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.

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.

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:

--storeBackend
emptyin-memory
postgres://… or postgresql://…PostgreSQL
anything elseSQLite 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=Aperture addresses a schema literally named Aperture — the one CREATE SCHEMA "Aperture" makes, not the one CREATE SCHEMA Aperture makes, which is aperture. 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_]*\z and be at most 63 bytes; anything else is APERTURE_CONFIG_INVALID at 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 INTEGER whose rows still hold text, which SQLite permits and which a type declaration alone would miss;
  • retired column namests_nanos, the audit log's old timestamp column, now occurred_at;
  • the older, unprefixed schema — databases predating the apt_ table prefix have no apt_ 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 called accounts or roles is 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.

  • The decision engine — the hot-path reader of GrantsForSubjects / GroupsForPrincipal.
  • The RBAC model — the entities Storage persists.
  • Seed & portability — the declarative loader/exporter over Storage.
  • Audit trail — the append-only trail this interface carries.
  • Error codesAPERTURE_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.

CodeMessageFixups
APERTURE_ACTION_UNDECLAREDaction is not declared on the object typeAdd 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_FETCHattribute provider returned an errorInspect 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_INVALIDattribute provider registration or attribute key is invalidRegister a non-nil provider, and at most one per slot; a duplicate is refused rather than replaced so one directory cannot silently shadow another.
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_UNREGISTEREDno attribute provider is registered for the slotRegister 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_UNKNOWNnot an attribute slotUse 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_DENIEDthe actor lacks the admin authority tier required for this mutationSchema 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_BOOTaperture failed to startCheck the APERTURE_* environment variables and any --config file.
Confirm the storage backend (memory or sqlite) is reachable.
APERTURE_CONFIG_INVALIDconfiguration is invalidValidate 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_DENIEDthe delegator may not bestow this grantBestow 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_DELEGATABLEthe permission is not flagged delegatableSet Delegatable on the permission definition to allow it to be bestowed.
APERTURE_ENTITY_UNMANAGEDthis deployment does not manage the entity kind the write targetedSet 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_INVALIDobject identity is malformedUse 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_DENIEDthe operator may not impersonate this targetImpersonate 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_EXPIREDthe impersonation session has expiredStart a fresh impersonation session; sessions are time-boxed and expire automatically.
APERTURE_INVALID_INPUTinput failed validationRe-check the request shape against the command or API contract.
APERTURE_INVALID_TOKENthe presented bearer credential failed verificationConfirm 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_INVALIDobject metadata violates the metadata value modelMake 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_FOUNDthe referenced entity was not foundConfirm the identifier exists in the current account scope.
APERTURE_PROVIDER_FETCHobject provider returned an errorInspect the wrapped cause for the underlying provider failure.
Return APERTURE_NOT_FOUND from the provider for an object that does not exist.
APERTURE_PROVIDER_INVALIDobject provider registration is invalidRegister 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_INVALIDa declared object reference is not usableRegister 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_MISMATCHa reference field's value does not identify an object of its declared target typeStore 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_UNREGISTEREDno object provider is registered for the object typeRegister 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.
APERTURE_RULE_EVALrule evaluation failedInspect the wrapped cause for the underlying evaluation failure.
Ensure the rule expression yields a boolean for the supplied context.
APERTURE_RULE_INVALIDrule AST is malformedGive 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_FOUNDthe referenced rule was not foundConfirm the rule reference exists in the configured rule source.
APERTURE_RULE_TYPE_ERRORrule failed expression type checkingCompare compatible types and make the rule evaluate to a boolean.
Call only functions registered with the rules engine.
APERTURE_RULE_UNKNOWN_VARIABLErule references an unknown variableReference variables under a known context root: object, principal, account, or action.
Check for a typo in the variable's root segment.
APERTURE_SCOPE_INVALIDscope strategy reference is malformedUse '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_UNCONFIGUREDscope enumeration requires an object lister that is not configurednot applicable
APERTURE_SCOPE_RULE_UNCONFIGUREDscope rule path requires a rule evaluator that is not configurednot applicable
APERTURE_SCOPE_UNKNOWN_STRATEGYscope strategy is not registeredUse a built-in strategy (literal, implicit, inclusive, exclusive) or register the custom key with the scope registry.
APERTURE_SQL_PROVIDER_AMBIGUOUSSQL provider's fetch statement returned more than one row for one keyFilter 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_CONNECTIONa declared database connection could not be resolved into a live poolSet 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_LITERALa declarative database connection carries a literal dsn instead of dsn_envReplace 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_QUERYSQL provider could not run its statementInspect 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_IDENTITYSQL provider could not turn a row's id column into a usable keyObject 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_SCANSQL provider could not read a row into metadataGive 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_STORAGEthe storage backend returned an errorInspect the wrapped cause for the underlying storage failure.
APERTURE_STORAGE_CONSTRAINTthe database refused the write because it would break referential integrityDelete 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_INCOMPATIBLEthe existing database was written by an incompatible build and cannot be upgradedRecreate 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_INVALIDthe provisioning template is malformedGive 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_PARAMthe template apply supplied invalid parametersSupply 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_UNAUTHENTICATEDthe request could not be resolved to a known principalPresent 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_UNIMPLEMENTEDthis surface is not yet implementednot applicable

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

CommandSummary
attributesInspect the attribute directories a seed wires, read one, or drop cached bags
bestowBestow (delegate) a grant you hold to another principal
bulkProvision or deprovision many grants in one transactional call
checkDecide whether a principal may take an action on an object
deleteDelete an entity (object-type|permission|principal|role|group|account|grant|membership)
enumerateList the objects a principal may act on
explainExplain why a decision resolved the way it did
exportExport the whole model to a single JSON/YAML state file (system-admin tier)
getRead one entity by id (object-type|permission|principal|role|group|account|grant)
identifiersList all valid instance ids of an object type from its provider
impersonateStart a time-boxed impersonation session (prints the session)
importApply a JSON/YAML state file as an idempotent transactional upsert (system-admin tier)
listList entities of a kind (object-types|permissions|principals|roles|groups|accounts|grants)
mcpServe the read-only Aperture MCP surface over stdio
putCreate or update an entity (object-type|permission|principal|role|group|account|membership|grant)
revokeRevoke a grant you previously bestowed
serveRun the Aperture HTTP server
templateManage and apply provisioning templates

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>
NameAliasesTypeDefaultUsage
--accountstringactive account (required for system-tier authority resolution)
--allboolclear EVERY slot's cache; takes no <slot> argument and no --id
--idstringdrop only this subject's cached bag (a bare principal or account id); omit to clear the whole slot
--principalstringauthenticated principal performing the mutation (env: APERTURE_PRINCIPAL)
--seedstringpath to a JSON/YAML seed model (defaults to the embedded example)
--storestringDSN 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>
NameAliasesTypeDefaultUsage
--accountstringactive account (required for system-tier authority resolution)
--fieldstringobject-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-jsonstringobject-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
--limitint0cap the number of returned records (<=0 means the default; the registry clamps it regardless)
--principalstringauthenticated principal performing the mutation (env: APERTURE_PRINCIPAL)
--seedstringpath to a JSON/YAML seed model (defaults to the embedded example)
--storestringDSN 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 the three attribute slots, the source each is wired to, and its cache settings

Prints one row per slot — user, machine, account — with the source the seed declares for it (csv, sql, or inline), the cache freshness window, the cached-bag cap, and how many bags this process currently holds.

THE TTL COLUMN IS THE REVOCATION WINDOW. A slot's cached bag keeps authorizing until it expires, so ttl is the longest a removed clearance can keep working. 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 cached column counts THIS process's cache. 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 in the seed file you passed and the configuration this process built from it. It contacts no provider and prints no subject key and no attribute value.

aperture attributes slots [options]
NameAliasesTypeDefaultUsage
--seedstringpath to a JSON/YAML seed model (defaults to the embedded example)
--storestringDSN 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]
NameAliasesTypeDefaultUsage
--delegatorstringprincipal bestowing the grant (env: APERTURE_PRINCIPAL) (required)
--filestringpath to a JSON grant body
--jsonstringgrant body as inline JSON
--seedstringpath to a JSON/YAML seed model (defaults to the embedded example)
--storestringDSN 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]
NameAliasesTypeDefaultUsage
--accountstringactive account (required for system-tier authority resolution)
--filestringpath to a JSON array of grant bodies
--jsonstringa JSON array of grant bodies
--principalstringauthenticated principal performing the mutation (env: APERTURE_PRINCIPAL)
--seedstringpath to a JSON/YAML seed model (defaults to the embedded example)
--storestringDSN 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>...]
NameAliasesTypeDefaultUsage
--accountstringactive account (required for system-tier authority resolution)
--grantstringgrant id to revoke (repeatable)
--principalstringauthenticated principal performing the mutation (env: APERTURE_PRINCIPAL)
--seedstringpath to a JSON/YAML seed model (defaults to the embedded example)
--storestringDSN 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>
NameAliasesTypeDefaultUsage
--accountstring"acme"active account the decision is scoped to
--seedstringpath to a JSON/YAML seed model (defaults to the embedded example)
--storestringDSN 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>]
NameAliasesTypeDefaultUsage
--accountstringactive account (required for system-tier authority resolution)
--account-idstringmembership account id (kind=membership)
--principalstringauthenticated principal performing the mutation (env: APERTURE_PRINCIPAL)
--principal-idstringmembership principal id (kind=membership)
--seedstringpath to a JSON/YAML seed model (defaults to the embedded example)
--storestringDSN 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.

aperture enumerate [options] <principal> <action> <pattern>
NameAliasesTypeDefaultUsage
--accountstring"acme"active account the enumeration is scoped to
--fieldstringobject-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-jsonstringobject-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
--limitint0cap the number of returned object ids (<=0 means the default)
--seedstringpath to a JSON/YAML seed model (defaults to the embedded example)
--storestringDSN 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
--viastringrestrict 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>
NameAliasesTypeDefaultUsage
--accountstring"acme"active account the decision is scoped to
--seedstringpath to a JSON/YAML seed model (defaults to the embedded example)
--storestringDSN 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]
NameAliasesTypeDefaultUsage
--accountstringactive account (required for system-tier authority resolution)
--formatstringoutput format: json (default) or yaml
--outstringwrite the state file to this path (default: stdout)
--principalstringauthenticated principal performing the mutation (env: APERTURE_PRINCIPAL)
--seedstringpath to a JSON/YAML seed model (defaults to the embedded example)
--storestringDSN 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>
NameAliasesTypeDefaultUsage
--seedstringpath to a JSON/YAML seed model (defaults to the embedded example)
--storestringDSN 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>
NameAliasesTypeDefaultUsage
--excludestringid to omit from the result (repeatable); expands an exclusive allowance
--seedstringpath to a JSON/YAML seed model (defaults to the embedded example)
--storestringDSN 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]
NameAliasesTypeDefaultUsage
--accountstringactive account (required)
--modestring"augment"augment|become
--operatorstringoperator principal (env: APERTURE_PRINCIPAL) (required)
--seedstringpath to a JSON/YAML seed model (defaults to the embedded example)
--storestringDSN 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
--targetstringtarget principal to impersonate (required)

aperture import

Apply a JSON/YAML state file as an idempotent transactional upsert (system-admin tier)

aperture import [options]
NameAliasesTypeDefaultUsage
--accountstringactive account (required for system-tier authority resolution)
--filestringpath to the JSON/YAML state file (default: stdin, treated as JSON)
--principalstringauthenticated principal performing the mutation (env: APERTURE_PRINCIPAL)
--seedstringpath to a JSON/YAML seed model (defaults to the embedded example)
--storestringDSN 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>
NameAliasesTypeDefaultUsage
--accountstringaccount to list grants for (required for kind=grant)
--seedstringpath to a JSON/YAML seed model (defaults to the embedded example)
--storestringDSN 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]
NameAliasesTypeDefaultUsage
--seedstringpath to a JSON/YAML seed model (defaults to the embedded example)
--storestringDSN 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>
NameAliasesTypeDefaultUsage
--accountstringactive account (required for system-tier authority resolution)
--filestringpath to a JSON entity body
--jsonstringentity body as inline JSON
--principalstringauthenticated principal performing the mutation (env: APERTURE_PRINCIPAL)
--seedstringpath to a JSON/YAML seed model (defaults to the embedded example)
--storestringDSN 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]
NameAliasesTypeDefaultUsage
--delegatorstringprincipal revoking the grant (env: APERTURE_PRINCIPAL) (required)
--grantstringid of the grant to revoke (required)
--seedstringpath to a JSON/YAML seed model (defaults to the embedded example)
--storestringDSN 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 serve

Run the Aperture HTTP server

aperture serve [options]
NameAliasesTypeDefaultUsage
--addrstring":8080"TCP address to listen on
--authstringauthenticator adapter: dev|oidc|parsec (overrides APERTURE_AUTH_MODE; defaults to dev — bearer is the principal id, no external IdP) (env: APERTURE_AUTH_MODE)
--enforce-membershipbooldeny 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)
--manage-accountsboolmanage 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-membershipsboolmanage 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-principalsboolmanage 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
--seedstringpath to a JSON/YAML seed model (defaults to the embedded example)
--storestringDSN 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

Manage and apply provisioning templates

aperture template <command>

aperture template apply

Apply a template transactionally into --account (account-admin tier)

aperture template apply [options]
NameAliasesTypeDefaultUsage
--accountstringactive account (required for system-tier authority resolution)
--id-prefixstringprefix for generated grant ids
--namestringtemplate name to apply (required)
--paramstringparameter as name=value (repeatable)
--principalstringauthenticated principal performing the mutation (env: APERTURE_PRINCIPAL)
--seedstringpath to a JSON/YAML seed model (defaults to the embedded example)
--storestringDSN 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
--versionint0template version (0 = latest)

aperture template delete

Delete a template version, or all versions (system-admin tier)

aperture template delete [options] <name>
NameAliasesTypeDefaultUsage
--accountstringactive account (required for system-tier authority resolution)
--principalstringauthenticated principal performing the mutation (env: APERTURE_PRINCIPAL)
--seedstringpath to a JSON/YAML seed model (defaults to the embedded example)
--storestringDSN 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
--versionint0template 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>
NameAliasesTypeDefaultUsage
--seedstringpath to a JSON/YAML seed model (defaults to the embedded example)
--storestringDSN 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
--versionint0template version (0 = latest)

aperture template list

List every template version

aperture template list [options]
NameAliasesTypeDefaultUsage
--seedstringpath to a JSON/YAML seed model (defaults to the embedded example)
--storestringDSN 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]
NameAliasesTypeDefaultUsage
--accountstringactive account (required for system-tier authority resolution)
--filestringpath to a JSON template body
--jsonstringtemplate body as inline JSON
--principalstringauthenticated principal performing the mutation (env: APERTURE_PRINCIPAL)
--seedstringpath to a JSON/YAML seed model (defaults to the embedded example)
--storestringDSN 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

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

FlagDefaultEnv sourcePurpose
--addr:8080TCP address to listen on.
--store(in-memory)SQLite DSN for the backing store. Empty ⇒ ephemeral in-memory store.
--seed(embedded example)Path to a JSON/YAML seed model. Empty ⇒ the embedded acme example fixture.
--authdevAPERTURE_AUTH_MODEAuthenticator adapter: dev, oidc, or parsec. The flag overrides the env var.
--enforce-membershipoffAPERTURE_ENFORCE_MEMBERSHIPDeny any decision whose principal is not a member of the active account, before grants are consulted.

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 a modernc.org/sqlite data source name — a file path or a file: 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:

  1. .env file — when you launch through make, a .env in the working directory is loaded and its keys are exported into the environment before the binary runs (include .env; export in the Makefile). This is the dotenv convenience; the binary itself simply reads its process environment.
  2. APERTURE_* environment variables — the primary configuration surface. The authenticator is built from these via auth.ConfigFromEnv (see below).
  3. Command-line flags — a flag that declares an env source overrides that env var. For example --auth oidc wins over APERTURE_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.

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.

VariableApplies toMeaning
APERTURE_AUTH_MODEallAdapter: dev | oidc | parsec. Empty ⇒ dev.
APERTURE_AUTH_PRINCIPAL_CLAIMoidc, parsecVerified claim mapped to the principal id (sub, email, …). Empty ⇒ sub. The dev adapter ignores it.
APERTURE_OIDC_ISSUERoidcOIDC issuer URL.
APERTURE_OIDC_AUDIENCEoidcExpected token audience.
APERTURE_OIDC_JWKS_URLoidcJWKS endpoint for signature verification.
APERTURE_PARSEC_KEYRINGparsecPath to the broker's persisted signing keyring (keyring.json) Aperture verifies brokered tokens against.
APERTURE_PARSEC_STATE_DIRparsecParsec 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.

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:

BenchmarkReports
BenchmarkCheckCachedAuditOff / …AuditOnsingle cached Check ns/op, allocs/op, and a computed p99-ns
BenchmarkCheckThroughputAuditOff / …AuditOnsustained parallel throughput as checks/sec
BenchmarkEnumerateBoundedbounded Enumerate; asserts the result never exceeds engine.DefaultEnumerateLimit

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, sort the per-op latencies, take the 99th percentile, assert < 1 ms.
  • throughput — run 200 000 cached Checks, divide by wall time, assert ≥ 10 000 checks/sec (a conservative single-goroutine floor; a real instance parallelises well above it).
  • both are run with audit on and off.

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.

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 offaudit on
mean latency~66 µs/op~70 µs/op
allocations34 allocs/op34 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.

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.

  • 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.
  • 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 Context map, 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:

  1. Note the APERTURE_* code from the output or logs.
  2. Find it in the Error Codes reference.
  3. Apply the listed fixup(s).

A few codes you are likely to meet operating a service:

CodeTypical triggerFirst move
APERTURE_CONFIG_INVALIDAn 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_BOOTserve 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_INCOMPATIBLEThe 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_CONSTRAINTA 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_TOKENA 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_DENIEDThe caller lacks the admin tier for a gated mutation.Expected for under-privileged callers; grant the tier or use an authorized principal.
APERTURE_NOT_FOUNDA 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 Context map hold the specifics.
  • Boot failures under serve are almost always configuration: --store DSN, --seed path/format, provider files declared in the seed, or an auth adapter that can't reach its IdP at startup (oidc discovers at boot). See Deployment.
  • If a decision is slow rather than wrong, that is a performance question — see Performance & the NFR and the TestCheckNFR gate.
  • 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 downwardscope, 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:

OperationQuestion it answers
CheckMay this principal perform this action on this resource?
EnumerateWhich resources/actions is this principal allowed?
ExplainWhy 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=0 end to end. No CGO packages (no geo/h3).
  • No dependency on Pulse. The rules engine renders its AST to an expr-lang/expr expression and compiles it in-process. See Rules engine.
  • No ORM / sqlc / migration tool. Storage is hand-written SQL over modernc.org/sqlite plus an in-memory implementation behind one Storage interface. 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

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)

PackageOwnsConcept 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)

PackageOwns
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.

  1. Declare the constant in the const block in errors/codes.go. Codes are SCREAMING_SNAKE, APERTURE_-prefixed, typed Code, 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"
    
  2. Append it to AllCodes — the slice every gate walks.

  3. Add a Registry entry with a Message and either at least one Fixup or FixupNotApplicable: true:

    APERTURE_WIDGET_JAMMED: {
        Message: "the widget resolver could not advance",
        Fixups:  []string{"retry the request", "check the widget provider health"},
    },
    
  4. Regenerate the reference table: make docs-gen reruns internal/docsgen/errcodes over errors.Registry and rewrites docs/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 (or FixupNotApplicable).
  • TestRegistryHasNoOrphans — the Registry contains nothing absent from AllCodes.
  • TestCodesAreScreamingSnakeNamespaced — every code is SCREAMING_SNAKE and APERTURE_-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)
}
  • Contains answers the hot-path question "is this concrete object a member?" and must never enumerate.
  • Members performs a bounded enumeration (bounded by scope.DefaultMaxMembers) for Enumerate-style callers, and must agree with Contains — anything Contains accepts 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 injected scope.ObjectLister; when none is configured, return APERTURE_SCOPE_LISTER_UNCONFIGURED rather 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)
}
  • Fetch returns an object's Metadata (a map[string]any); a missing object must return an APERTURE_NOT_FOUND coded error so the Registry can distinguish "absent" from a fault. A plain error is wrapped as APERTURE_PROVIDER_FETCH.
  • Return a fresh map per object — cached Metadata is treated as read-only and is never copied on read, so a shared map would race readers.
  • List is the unfiltered enumeration; Query honours a provider.Filter (Pattern, Fields, Limit). Aperture re-enforces Pattern and Limit on 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) or APERTURE_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 oidc and parsec adapters (the dev adapter is the one exception — the bearer is the principal). Follow auth/oidc.go / auth/parsec.go as the models and add a *_test.go beside 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.

  1. Identity — add a name constant and a description constant in mcp/toolmeta/meta.go, and a {Name, Description} row to Meta(). This is the single source of truth both the core and the mcp/gosdk adapter read, so they never drift.
  2. Contract — define the typed In/Out structs in mcp/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 typed any so the JSON-Schema reflector stays error-free.
  3. Handler — add a func(context.Context, *service.Service, In) (Out, error) in mcp/handlers.go. Read-only only: every tool calls a facade READ or DECISION method (Check/Enumerate/Explain/Simulate/Get*/List*); no handler may mutate.
  4. Wire it — add toolmeta.ToolYours: makeInvoke(handleYours) to the map in mcp/tools.go, and a register(...) call in mcp/schema.go's init so its input/output schemas are reflected.

Gates (mcp/surface_test.go, mcp/firewall_test.go):

  • TestCatalogMatchesToolmeta — the catalog matches toolmeta.
  • 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 (only mcp/gosdk may).

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:

  1. Declare a NodeType constant in rules/ast.go (alongside NodeAnd, NodeCompare, NodeVar, …). Add any fields it needs to the Node struct.
  2. Validate — add a case to Node.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.
  3. Render — add a case to Node.render() (reached via Node.Expr()) that emits the node's expr-lang/expr spelling. 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.

  1. Declare the method (and any new request/response messages) on ApertureService in internal/wire/rpc/service.proto.
  2. Regenerate the Twirp + protobuf code with make proto (requires protoc
    • protoc-gen-go + protoc-gen-twirp). The generated service.pb.go / service.twirp.go are committed — CI does not regenerate them — so commit the regenerated files with your change.
  3. Implement the method on twirpHandler in internal/server/twirp.go. The handler is a thin translator: decode the request, resolve the actor/principal from context, call the service.Service facade, and encode the result. Return APERTURE_* coded errors verbatim; never put policy logic here.
  4. Document it — the RPC reference is hand-authored over service.proto (there is no generator; drift is accepted). Add the new method to docs/src/surfaces/rpc-reference.md.
  5. 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 codeerrors/codes.go — the AllCodes slice and a Registry entry with a Message + FixupsTestCodesHaveFixups
A skills/*.md docits YAML frontmatter (name matching the file stem + description)TestEverySkillHasFrontmatter
The Update-Demand ruleskills/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. mappedDriverTypesthe other half, plus the skills/ doc that restates itthe 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 — every APERTURE_* code has a Registry entry with a Message and at least one Fixup (or FixupNotApplicable: true). Adding an error code without its remediation metadata fails here. See Adding an error code.
  • TestRegistryHasNoOrphans — the Registry contains nothing that is absent from AllCodes; the two lists cannot diverge.
  • TestCodesAreScreamingSnakeNamespaced — every code is SCREAMING_SNAKE and APERTURE_-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 — every skills/*.md has a name (matching its file stem) and a description in 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. TestDriverValueMappingTableMatchesTheTypeSwitch is the newest: it parses sqlprovider/values.go with go/ast and fails if the driver-value type switch and mappedDriverTypes disagree, 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

ToolVersionWhy
Go1.26.1The module targets this toolchain (go.mod).
CGO_ENABLED0 (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.
mdBookv0.5.2Only 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:

ToolPurpose
staticcheckStatic analysis for make lint. CI installs it; locally make lint degrades to go vet when it is absent.
protoc + protoc-gen-go + protoc-gen-twirpOnly to regenerate the RPC layer with make proto. The generated code is committed, so you do not need these for a normal build.
nodeOnly 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

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

TargetRunsNotes
make buildgo buildbin/apertureDefault goal. CGO_ENABLED=0, -ldflags="-s -w" -trimpath.
make testgo test ./...The full unit/integration suite. Does not include the NFR benchmark gate (see below).
make fmtgo fmt ./...Formats the tree.
make vetgo vet ./...Standard vet checks.
make lintgo vet + a static analyserRuns staticcheck if present, else golangci-lint, else prints a notice and runs vet only. 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'
GateEnforcesTrips when you…
TestCodesHaveFixupsEvery 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.
TestRegistryHasNoOrphansThe Registry contains nothing absent from AllCodes.Add a Registry entry but forget to list the code in AllCodes (or vice versa).
TestCodesAreScreamingSnakeNamespacedEvery code is SCREAMING_SNAKE and APERTURE_-prefixed.Name a code apertureFoo or drop the APERTURE_ prefix.
TestUpdateDemandDocPresentThe Update-Demand seed doc skills/update-demand.md exists with frontmatter.Delete or de-frontmatter the rule's own documentation.
TestEverySkillHasFrontmatterEvery 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.

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.go only assembles the binary and calls into internal/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.Errorf across package boundaries — wrap in a coded error via errors.New / Newf / Wrap / Wrapf.
  • Codes are SCREAMING_SNAKE, APERTURE_-prefixed, declared in errors/codes.go, listed in AllCodes, and each has a Registry entry with a Message and at least one Fixup (or FixupNotApplicable: true).
  • An error already carrying an APERTURE_* code passes through verbatim — the wrappers never re-stamp it. Recover the code with errors.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=0 is 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/expr expression and compiles it in-process. Aperture uses expr-lang/expr directly; it does not import Pulse.
  • Storage is hand-written SQL over modernc.org/sqlite (pure-Go) plus an in-memory implementation behind one Storage interface — 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 test runs go test ./.... Tests are table-heavy and co-located with the code they exercise (*_test.go).
  • The server has *_smoke_test.go smoke tests; end-to-end tests live in cmd/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 TestCheckNFR under bench/, kept out of make 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 #ff3399 is 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 .otf is 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:42 is 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 run make 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:

OutputGenerated from
docs/src/reference/error-codes.mderrors.Registry + AllCodes in errors/codes.go
docs/src/reference/cli.mdthe 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 its Fixups in errors/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 over internal/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

TargetRegeneratesRun it after you change…CI drift gate?
make docs-gendocs/src/reference/error-codes.md, docs/src/reference/cli.mderror codes in errors/codes.go; CLI in internal/cli/No — manual
make protointernal/wire/rpc/*.pb.go, *.twirp.gointernal/wire/rpc/service.protoNo — manual
make vendor-reteinternal/server/static/vendor/rete/rete.min.jsRete.js version / pluginsNo — 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

  1. Branch off main. One feature or fix per PR — keep it focused.

  2. Make your change in the library first (see Style & testing conventions).

  3. Add tests in the same PR. New functionality ships with its tests; there are no follow-up-PR commitments for test gaps.

  4. 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.

  5. Update the matching skills/*.md doc if you changed a registered surface — the Update-Demand rule makes this a non-skippable CI gate.

  6. Run the local gates:

    make fmt
    make test
    make vet
    make lint
    

    If you changed docs, also confirm make docs builds cleanly.

  7. 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 via make test, including the five non-skippable gates: TestCodesHaveFixups, TestRegistryHasNoOrphans, TestCodesAreScreamingSnakeNamespaced, TestUpdateDemandDocPresent, and TestEverySkillHasFrontmatter (see gates).
  • go vet and staticcheck via make lint (CI installs staticcheck).
  • 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.md when error codes or CLI flags changed.
  • The matching skills/*.md update 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.