Seed & portability
The seed package loads a declarative authorization model — a single JSON or
YAML document — into a model.Storage, and exports one back out. It is both the
human-authored on-ramp behind the aperture check / aperture serve demo and the
full round-trip state file the model portability endpoints use.
One document, both directions
A Document is a flat list of each entity kind. The field tags cover both YAML
and JSON, so either format decodes into the same shape:
accounts: [{ id: acme, name: Acme }]
memberships: [{ principal: alice, account: acme }]
object_types: [{ name: document, actions: [read, write] }]
permissions: [{ id: doc.read, object_type: document, action: read, scope_strategy: implicit }]
principals: [{ id: alice, kind: user, roles: [reader] }]
roles: [{ id: reader, name: Reader, permissions: [doc.read] }]
groups: [ ... ]
grants: [{ id: g1, account: acme, subject: { kind: principal, id: alice }, permission: doc.read, object: "account:acme/document:*", effect: allow }]
templates: [ ... ]
rules: [ ... ]
connections: { ... } # named database connections — wiring, SHARED (names only)
providers: [ ... ] # object-metadata providers — wiring, SHARED
field_types: [ ... ] # declared types for metadata fields — wiring, SHARED
attribute_providers: [ ... ] # EXTERNAL sources for subject bags — wiring, SHARED
objects: [ ... ] # inline object metadata — wiring, LOCAL
attributes: [ ... ] # inline SUBJECT attributes (principal / account) — wiring, LOCAL
The first ten sections are model state and the last six are runtime wiring.
Of the six, four are shared — aperture wiring push writes them to the
database every instance of a deployment already holds — and two are local to the
instance whose file lists them. See
What is not in the file below, and
aperture wiring for the command surface.
connections: is a map keyed by name, not a list: the name is what a
provider entry's connection: refers to, and a map cannot declare one twice.
Every field mirrors its model counterpart in declarative form. The Document
started as a minimal seed shape and was generalized to the complete model, so an
export file is a strict superset of a seed file: a seed that omits
templates/rules/providers/objects/field_types/attributes/attribute_providers
loads unchanged, and a full export reloads through the very same path. The field
set is additive-only, so old seeds keep loading.
Rule ASTs are carried as raw JSON — exactly the rules package's canonical
Node serialization — so the file never invents a second rule format; it is the
same shape the node editor reads and writes.
Loading (import)
// From bytes, explicit format:
err := seed.Load(ctx, store, data, seed.FormatYAML)
// From a file — format inferred from the extension (.json ⇒ JSON, else YAML):
err := seed.LoadFile(ctx, store, "model.yaml")
Parse decodes the document; Apply upserts it into the store in dependency
order: accounts, object types, permissions, principals, memberships, roles,
groups, grants, templates, then rules. Each write goes through the storage layer's
own validation — a malformed entity surfaces the same coded error a programmatic
Put would (e.g. APERTURE_ACTION_UNDECLARED for a permission naming an
undeclared action). Rule ASTs are additionally validated against the rules
engine's contract before storing, so an import rejects a structurally broken rule
(APERTURE_RULE_INVALID) rather than persisting one the engine could never
compile.
Applyis not transactional — a failure may leave a partial model. This is acceptable for the seed-and-demo use case. (The mutation API's bulk endpoints useStorage.Atomicwhen all-or-nothing is required.)
The YAML path routes through JSON internally (yaml → generic → json → Document)
so the raw-JSON rule AST decodes by exactly the same rules the JSON path uses.
The committed example
seed.Example is the embedded org → project → document fixture stamped to
account acme (seed.ExampleAccount). It is what aperture check loads when no
--seed file is supplied, and it backs the end-to-end test.
Exporting
Export(ctx, store) reads the complete model back out into a Document, and
Marshal(doc, format) renders it to on-disk bytes:
doc, _ := seed.Export(ctx, store)
out, _ := seed.Marshal(doc, seed.FormatJSON)
Export captures every source-of-truth entity: accounts, memberships, object types, permissions, principals, roles, groups, grants, templates, and rule ASTs. Two properties make a round-trip trustworthy:
- Byte-stable. Every slice is emitted in a stable order (sorted by id, name, or natural key) and each rule AST is re-serialized to the rules package's canonical form, so a re-export of an unchanged model is byte-identical and human-diffable.
- Wildcard edges are preserved. Memberships and grants stamped to the wildcard
account
*(the cross-account super-admin reach) are not among the real accounts, soExportqueries*explicitly — omitting it would silently drop a super-admin's reach on export/import.
Inline object metadata
For a small or fixed object set, objects: declares metadata in the seed file
itself, with no separate CSV beside it. YAML nests natively, so this is the most
direct way to author the arrays and nested objects the
value model admits:
objects:
- id: account:acme/brand:1
metadata:
tier: gold
seats: 5
tags: [premium, launch]
owner:
dept: eng
lead: alice
- id: account:acme/brand:2 # metadata: may be omitted entirely
- id: account:acme/app:be
metadata: { tier: gold }
The object-type is derived from the identity's terminal segment —
account:acme/brand:1 is a brand — never declared separately, so one fact in
one place cannot disagree with itself. Entries of different types may be
interleaved freely; BuildRegistry groups them and registers one in-memory
provider.Static per type, with
a TTL of 0 (the data cannot go stale, because nothing can change it). Within a
type, declaration order is preserved — it is the order List and Query
return.
The provider it builds is a full one: Fetch (with APERTURE_NOT_FOUND for an
undeclared id), List, and Query honouring Pattern, Limit, and Fields on
the same contract every other provider
implements — a collection field matches by membership, everything else by typed
equality.
Values are validated against the shared value model when the document is
built, before any Check can see them, and numbers are normalised exactly as
every other loader normalises them: an exact integer that fits int64 becomes an
int64, anything else a float64. That is what keeps object.seats == 5 from
answering differently depending on whether the object came from a seed file, a
JSON seed file, or a CSV :int column.
A missing or duplicate id, metadata that is not a mapping, or a value the
value model rejects is APERTURE_CONFIG_INVALID naming the object id and the
field (with the inner APERTURE_METADATA_INVALID kept in the chain); a malformed
id is APERTURE_IDENTITY_INVALID. Ids are deduplicated across the whole
section, not per type — the same id declared twice is the same object declared
twice, however it is spelled.
Declared field types
A CSV header can say hired_at:date; a YAML mapping has nowhere to put that, so
hired_at: 2026-02-30 in an objects: entry loads happily as an ordinary string
and only shows up months later as a rule that silently never matches. The
optional field_types: section is the missing declaration:
field_types:
- object_type: brand
fields:
hired_at: date
last_seen: datetime
objects:
- id: account:acme/brand:1
metadata:
hired_at: "2026-03-04"
last_seen: "2026-03-04T12:30:00Z"
Each entry names an object_type — matched against the identity's terminal
segment, exactly as providers: is — and maps field names to a type. The
vocabulary is exactly two words, date and datetime, lower-case and exact:
the CSV loader's column-suffix spelling with the colon removed, so the same two
words mean the same two things in both loaders. timestamp, Date, and time
are APERTURE_CONFIG_INVALID, never a silently ignored declaration. Each object
type may be declared at most once.
Quote date values in YAML. YAML resolves an unquoted calendar day as a
!!timestamp, and the loader's YAML path normalises through JSON, sohired_at: 2026-03-04arrives already widened to2026-03-04T00:00:00Zand a field declareddaterejects it. The widening happens inside the YAML decoder, before the seed package sees the document, so it cannot be undone — the rejection names the fix when the instant is exactly midnight. A JSON seed has no such trap, and the two formats agree on every quoted value.
Declared values are validated and canonicalised at BuildRegistry time,
through the same provider.ParseDateValue
the CSV loader uses — this document never re-implements what a date is. The
canonical text is what is stored, so 2026-03-04T01:02:03.456Z and
2026-03-04T01:02:03 both become 2026-03-04T01:02:03Z and two objects naming
one instant hold one string, which is what makes a Filter.Fields equality
predicate over the field mean anything. The declared type also fixes the
granularity: a date field rejects a timestamp and a datetime field rejects
a bare day, rather than quietly widening the day to midnight.
Four properties are worth stating because each is the first question a reader asks:
- A declared field is not a required field. The section declares a type,
not a requirement: an object that omits the field is perfectly valid. An
explicitly empty value omits the field rather than storing
""— the same rule an empty CSV cell follows, because an absent date differs meaningfully from any date and a zero time would silently satisfy everybeforerule ever written against the field. - Declaring a type for an object type with no
objects:entries is legal. The entries may be arriving, or the type may be served by aproviders:entry. It registers nothing on its own; the declaration itself is still validated, so a typo fails the build rather than waiting for an entry to expose it. - It applies to
objects:only, never to provider-loaded rows. Aproviders:entry carries its own typing (the CSV:date/:datetimesuffix). One type declaration living in two places could disagree with itself, which is exactly what this document's derive-the-type-from-the-identity rule avoids elsewhere. - It is not a general metadata schema. No
required:, nodefault:, noenum:, nopattern:, noint/float/bool, no nested field paths. The value model already governs shape, depth, and size; the one thing it cannot govern is which strings a host means as dates, because nothing in a string says so.
A rejection is APERTURE_CONFIG_INVALID naming the object id and the field
and carrying the provider.DateReason for a parse failure (a granularity
mismatch is not a parse failure and carries no reason, so DateReasonOf
correctly reports none). It never carries the value — a date is frequently
personal data, and an error is a thing that gets logged.
Like providers: and objects:, this is runtime wiring: Apply writes no
row for it and an export never reproduces it.
When both sections claim a type
providers: and objects: can each claim the same object type. Precedence is
type-level and total:
If a
providers:entry exists for an object type, the declared provider —csvorsqlalike — wins and every inlineobjects:entry for that type is discarded.
There is no object-level merge, no field-level merge, and no fallback. An
inline id the provider's source happens to lack is simply not resolvable —
Fetch returns APERTURE_NOT_FOUND and enumeration never lists it, exactly as
if the entry had never been written. A field only the inline entry declared does
not appear on an object the source does carry.
That is deliberate. Field-level merging is the most useful-sounding behaviour and the most impossible to debug: a rule reading a field the CSV or table silently did not override is a support ticket nobody can reproduce. Predictability wins.
This is the default. A collision builds, it does not fail. Pointing a type at
a CSV or a table while its inline entries are still in the file is an ordinary
migration step, not an authoring fault — a seed that booted yesterday must not
refuse to boot today because someone added a providers: row.
The discard is not silent, though. BuildRegistry needs no logger to say so,
because the document can be asked directly:
reg, err := doc.BuildRegistry(dir)
if err != nil {
return err
}
if types := doc.ProviderCollisions(); len(types) > 0 {
slog.Warn("seed: inline objects discarded, providers: entry wins",
"object_types", types)
}
ProviderCollisions returns exactly the object types whose inline entries the
build discarded — sorted, deduplicated, and object types only, never object
ids (an id can embed an account, and this value is destined for a log line). It
reads the document alone — no file IO, no registry — so it answers the same
before and after a build, and a host surfaces it however it already surfaces
things. Nothing in seed picks a logger for you.
A host that would rather read the overlap as an authoring mistake — a checked-in seed nobody is mid-migration on — opts into a refusal:
reg, err := doc.BuildRegistry(dir, seed.StrictProviderCollision())
That returns APERTURE_CONFIG_INVALID naming every colliding object type
(sorted, in both the message and the error context — never the object ids). It is
Go wiring rather than a seed-file key on purpose: the file stays a plain
declaration of what exists, and the choice to make an ambiguous one fatal sits in
code, where a reviewer sees it.
Validation is independent of precedence. Every inline entry is checked —
id, identity, duplicates, the value model, and its
declared field types — before any type is discarded,
so a malformed declaration fails the load whether or not its type ultimately
loses to a providers: entry. Otherwise a document would silently stop being
validated the day someone added a CSV for one of its types. The canonicalised
values are then discarded along with the rest of those entries: a field_types:
declaration never reaches the rows a declared provider serves — a SQL provider's
column typing lives in its statement's casts, not here.
Two providers: entries for one type remain a duplicate registration,
APERTURE_PROVIDER_INVALID — that is a straight contradiction with no winner to
pick, not a precedence question.
Database-backed providers
A providers: entry declares its kind. csv names a file resolved relative to
the seed file; sql names two statements run against a connection
declared in the document's top-level connections: block. Together they make a
database-backed deployment a no-Go-code experience:
connections:
main:
dsn_env: APP_DATABASE_URL # required — the ONLY way to supply a DSN
max_open_conns: 8 # optional
max_idle_conns: 4 # optional
conn_max_lifetime: 1h # optional
query_timeout: 3s # optional
providers:
- object_type: brand
kind: sql
connection: main
get_one: SELECT tier, seats, to_jsonb(tags) AS tags FROM brands WHERE id = $1
get_all: SELECT 'brand:' || b.id AS id, b.tier, b.seats FROM brands b
ttl: "30s"
| Provider key | Meaning |
|---|---|
connection | the connections: entry to read through. Required for kind: sql; a name with no matching entry is a hard error at build. |
get_one | the "get one" statement, taking exactly one placeholder, to which the identity's terminal segment value is bound |
get_all | the "get all" statement, taking no parameters and selecting each row's full identity as the id column. Required alongside get_one — a provider that could be fetched from but not enumerated would answer List with an error, and an errored enumeration reads as "no access". |
id_column | the get_all result column holding the identity. Default id. |
references | declared object references: field name → target object-type. See Declaring a reference. Works on any kind. |
ttl / max_size | the per-type cache options, as for any provider entry |
The statements are the developer's own, and the SELECT list is where a
column's type is decided — there is no per-column type declaration here the way
a CSV header carries :int. Cast arrays with to_jsonb(...), day-granular dates
with ::text, and a numeric with ::float8 or ::text; compose the identity
in the id column. Getting a cast wrong is not always an error — SELECT tags
yields the raw array literal as a string, and every membership predicate over
it then silently matches nothing. See
the SQL provider.
Project the same columns in get_one and get_all (the id column aside) unless
you mean not to. The two SELECT lists are what decide whether an enumeration may warm
the registry's per-type metadata cache, and an unequal pair silently gives that up —
correctly, because a bag from a narrower listing would make a rule read
object.<dropped_field> as absent, which denies an inclusive grant and stops an
exclusive one excluding. The pairing is compared from the columns the statements
really return, so there is no key here to declare it with. See
The listing and the fetch must be the same bag.
There is no dsn: key
A seed file is a committed artifact, and a DSN carries a password. Naming an
environment variable is therefore not the recommended spelling — it is the only
one. A literal dsn: is refused by Parse with
APERTURE_SQL_PROVIDER_DSN_LITERAL, before the document is usable for
anything: not by Apply, not by an export round-trip, not by a tool that only
wanted to read the object types. The refusal names the offending connection and
never the value, and any DSN is redacted out of driver messages these errors
carry.
One pool per named connection, and its defaults
The pool is opened once per declared connection and shared by every provider
entry naming it: three kind: sql entries over one database are three providers
and one pool. Every declared connection is opened, not only the referenced
ones — a declared-but-unused name is far more likely a typo'd connection: than
a deliberate spare. The query timeout belongs to the connection, since it is a
property of the database being read.
| Key | Default | Notes |
|---|---|---|
query_timeout | 5s | bounds one statement. Must be positive — there is no "no timeout" setting, because an unbounded statement under Check is an unbounded decision. |
max_open_conns | 10 | deliberately not database/sql's unlimited default; negative restores it. An unbounded pool lets a burst of Checks exhaust the host's server. |
max_idle_conns | 5 | zero or negative retains none. Provider traffic is bursty, so keeping half the pool warm avoids re-paying TLS and auth on every wave. |
conn_max_lifetime | 30m | "0" means reuse forever. A finite lifetime is what survives a connection proxy or a failover pair. |
Durations are Go durations ("30m", "500ms"). An unparseable one, a negative
conn_max_lifetime, or a non-positive query_timeout is
APERTURE_SQL_PROVIDER_CONNECTION at build.
Pools have a lifetime, so the build signature changes
A document that declares connections: cannot be built through the
one-return BuildRegistry — a pool has a lifetime that signature cannot hand
back, so rather than open pools nothing can close, it refuses the document with
APERTURE_SQL_PROVIDER_CONNECTION naming the call that works:
reg, conns, err := doc.BuildRegistryWithConnections(dir)
if err != nil {
return err
}
defer conns.Close()
This is the one place a valid seed file fails the simple call. Every other
document — csv providers, inline objects:, no connections: block — still
builds through BuildRegistry unchanged.
*seed.Connections is the registry's other half: a *provider.Registry holds no
resources, the pools beneath a SQL-backed entry do. It is always non-nil on
success and empty (Close a no-op) for a document declaring no connections, so a
caller defers unconditionally without asking which kinds the seed used. Close
is idempotent and joins the failures of every pool it closes. Aperture's own CLI
owns and closes them for the duration of a command.
Nothing is dialled at build
sql.Open is lazy and Aperture does not ping. A wrong host, port, password,
or a database that is simply down surfaces on the first decision that touches a
SQL-backed object-type, as APERTURE_SQL_PROVIDER_QUERY — not at startup.
Making registry construction wait on a round-trip would make every
aperture check a network operation, including the ones that touch no SQL-backed
type, and would stop a process from booting while the host's database is still
starting.
Everything Aperture can decide without dialling is eager, because a connection
that only fails under a decision fails as a denial: the dsn_env variable
must be set and non-empty, the durations must parse, query_timeout must be
positive, every connection: must name a declared connection, and both
statements must be present. A failure takes the whole build with it and closes
every pool opened so far, so a failed build strands nothing.
Declaring a reference
Any providers: entry — csv or sql — may declare object references: a
mapping from a metadata field name this provider serves to the target
object-type its values identify. It is an application-level foreign key with no
database constraint behind it, so the document is where it is stated:
providers:
- object_type: dataset
kind: sql
connection: main
get_one: SELECT d.tier, to_jsonb(d.brand_ids) AS current_brands FROM datasets d WHERE d.id = $1
get_all: SELECT 'account:acme/dataset:' || d.id AS id,
to_jsonb(d.brand_ids) AS current_brands
FROM datasets d
references:
current_brands: brand
- object_type: brand
kind: sql
connection: main
get_one: SELECT b.region FROM brands b WHERE b.id = $1
get_all: SELECT 'account:acme/brand:' || b.id AS id, b.region FROM brands b
That declaration is what lets an enumeration be restricted to what a holder
object names — aperture enumerate … --via account:acme/dataset:x.current_brands,
"which brands belong to dataset x?".
Four rules govern the block, and each closes a door deliberately:
- It is declared on the HOLDING side only — the type whose provider actually
returns the field. There is no inbound spelling on
brand:brandhas no column listing its datasets, so an inbound declaration would describe a derived view with nothing to attach to, and a second referencing field (archived_brands: brand) would make the unnamed reverse edge ambiguous. - It is a closed set of one descriptor kind. A field maps to a target
object-type and to nothing else. Do not add a
type:key here: the loader is the single typing mechanism (a CSV column suffix, a cast in the developer's SQL), and a second place to declare a type is a second place for the two to disagree. - The field's values are full canonical identities —
"account:acme/brand:1", composed by the developer where the data is loaded, scalar for one and a list for many. Spell them exactly as the object lister yields them; a bare"brand:1"in an account-scoped deployment passes the declaration check and then resolves to nothing. - The blocks are applied last, in a second pass, once every type is
registered — so a reference may name a target declared further down the file,
or one served by the
objects:section. Order in the file never matters.
A target no provider serves is APERTURE_PROVIDER_REFERENCE_INVALID at build,
naming the field and the target. A field name no object happens to carry is not
an error at all: metadata fields are discovered at fetch, not declared, so it
simply resolves to nothing.
Like every other providers: key, references: is runtime wiring, not model
state: Apply writes nothing for it and an export reproduces none of it. It is
shared with the rest of the section — aperture wiring push flattens the map into
apt_wiring_provider_references — so a second instance reads the same declarations.
See Declared references for what a declaration
buys and the security semantics of enumerating through one.
Inline subject attributes
objects: says what Aperture knows about the thing being acted on. attributes:
says what it knows about the party asking — a principal's department, an
account's plan — so a small deployment can make principal.department mean
something with no directory, no CSV, and no Go:
attributes:
- subject: user
id: alice
metadata:
department: eng
clearance: 3
teams: [platform, infra]
- subject: machine
id: ci-runner
metadata: { department: eng }
- subject: account
id: acme
metadata: { plan: enterprise }
subject: names one of the three attribute slots — user or machine for a
principal, account for the tenant a decision is made in. The set is closed: it
is the parties a decision has. It is spelled subject: rather than kind:
because account is a slot but not a principal kind, and a key called kind:
would read as model.PrincipalKind. An unknown value is
APERTURE_ATTRIBUTE_SLOT_UNKNOWN naming the entry and the three legal subjects.
id: is the bare attribute key — a principal id, or an account id — and
Aperture never parses it. There is no type to derive from it the way an objects:
id derives its object-type from its terminal segment: an attribute key is an
opaque handle into the host's directory with no segment structure, which is
exactly why the slot has to be declared. Keys are deduplicated per slot, not
across the section: a tenant called acme and a service principal called acme
are two unrelated subjects, while the same key twice under one subject would let
the last writer silently win.
metadata: is the ordinary value model,
validated at build and with numbers normalised exactly as objects: normalises
them — there is one value model in Aperture and an attribute bag is a value in it.
A rejected value keeps the model's own APERTURE_METADATA_INVALID (the code whose
fixups name the legal shapes and the two caps), with the offending entry added to
the message; a missing subject:/id:, a duplicate pair, or a metadata: that
is not a mapping is APERTURE_CONFIG_INVALID. The account wildcard "*" is never
a legal key — it would ask for the attributes of every account at once — and is
refused with APERTURE_ATTRIBUTE_PROVIDER_INVALID.
Document.BuildAttributeRegistry(baseDir) turns the block into a live
*provider.AttributeRegistry, registering one in-memory provider per declared
slot — in that slot's local layer, with a TTL of 0. It always returns a usable
registry, so a host wires it unconditionally:
attrs, err := doc.BuildAttributeRegistry(filepath.Dir(seedPath))
if err != nil {
return err
}
eng := rules.NewEngine(ruleSource, objectFetcher, rules.WithPrincipalResolver(attrs))
The kind picks the slot, and a missing source is not a failed decision: a
subject with no entry — or a slot with no provider at all — evaluates against the
floor bag ({id, kind}), so a rule reading an attribute nobody declared is
deny-safe rather than a non-decision. See
Wiring a *provider.AttributeRegistry.
To back a slot with the host's real directory instead of an inline list, declare
it under attribute_providers:. The two sections are
not exclusive: when both claim one slot, the external entry becomes that slot's
shared layer and the inline bags layer under it — see
Precedence: two layers, and the shared layer wins.
Why it is not a metadata: field on principals:
principals: and accounts: are model state — rows Apply writes and
Export reads back. An attribute bag is not: it belongs to the host's directory,
and Aperture persists none of it (there is no column for it, by design). Hanging
the bag off a model entry would put wiring inside state, and the export would then
be one of two bad things — lossy, because it silently dropped the bags it
could not read out of storage, or untruthful, because it invented them. Its
own key keeps the line exactly where the other wiring sections already keep it.
External attribute sources
attributes: lists bags inline. attribute_providers: points a slot at the
host's real directory instead — a CSV export, or the users and accounts
tables the deployment already has — one entry per slot:
connections:
main:
dsn_env: APP_DATABASE_URL
attribute_providers:
- subject: user
kind: sql
connection: main
get_one: SELECT department, clearance FROM users WHERE id = $1
get_all: SELECT u.id AS id, u.department, u.clearance FROM users u
ttl: 60s
- subject: machine
kind: csv
path: machines.csv
subject: names the slot exactly as it does on attributes: — user,
machine, or account — and each slot may be declared at most once in this
section. Declaring it in attributes: as well is not a conflict: that block
becomes the slot's local layer, under this entry
(Precedence).
kind: is the implementation, csv or sql; the two words are why the slot is
spelled subject: here rather than kind:. Everything Aperture can check
without reading a file or dialling a database is checked at build: the
subject must name a slot, the kind must be one this build knows, a csv entry
must carry a path:, a sql entry must carry a connection: naming a
declared connections: entry plus a get_one:, and a ttl: must parse. A
source that only failed under a decision would fail as a denial.
ttl: and max_size: are per slot, not per document: the three slots have
genuinely different change rates and cardinalities, and one number covering all
of them would tune for whichever entry was declared last. ttl: "0" never
expires. A slot's TTL is the window a revoked clearance keeps authorizing
for — see aperture attributes, which reads it back and
can close it. It is also per layer: a slot with an external source and an inline
block caches each independently, so this ttl: is this source's own window and the
inline bags' 0 is not averaged into it.
dsn: is refused by name wherever it appears, here as on a providers:
entry: credentials belong to a connections: entry's dsn_env:.
declared_keys: — the keys a slot guarantees
An entry may declare the attribute keys it guarantees. The key is optional, and omitting it is legal:
attribute_providers:
- subject: user
kind: sql
connection: main
get_one: SELECT department, clearance FROM users WHERE id = $1
declared_keys: [department, clearance]
Declaring does two jobs with one list, and each fixes what the other cannot.
Key enforcement. A rule may then read only the keys the set names on that slot. Declaring nothing opts out, and a slot with no declared set behaves exactly as every slot did before the key existed.
Reservation. The declaring layer becomes the only layer that answers those
keys. A slot holds a shared layer and a local one, and this section is the shared one,
so the inline attributes: block contributes nothing to
a declared key — not when the two disagree, not when this entry's own bag omits it
for one row (a SQL NULL is an absent field), and not when this entry has no
record for the subject at all. Keys outside the set are untouched.
Together they make a local attribute layer safe in both directions. Enforcement covers
the keys a local layer adds: they are unreachable from any rule the deployment can
validate — inert, rather than a second answer to a deployment-wide grant.
Reservation covers the keys inside the set, where the merge used to fall through to
the local file on an absent value or an absent record. The second half is a
revocation control: deleting a subject from the shared directory removes every
declared key for them, on every instance, whatever a local file still says — which a
ttl: cannot do, since the inline layer is registered with ttl: 0 and has nothing
stale to drop.
The declared set therefore lives on the shared entry and nowhere else: a local layer able to narrow or widen it would be one machine changing which keys a deployment-wide rule may name, and which keys the deployment's own directory is allowed to answer.
Reservation is opt-in and is not a discard — a slot that declares nothing reserves nothing, and a key outside a declared set still answers from the local layer. It also cannot reach the engine's floor. The registry, the two layers, and the revocation window is the full account.
The set is a plain list of names, with no per-key type information — the simplest
form that round-trips, and the right one, because the metadata value
model already governs shape and field_types: already governs the
declared date types. A second typing mechanism would be a second place for two
declarations about one key to disagree.
There are three states, not two:
| Written | State | Effect |
|---|---|---|
declared_keys: absent (or null) | not declared | the slot is opted out of key enforcement |
declared_keys: [] | declared empty | the slot is opted in and permits no key |
declared_keys: [a, b] | declared | permits a and b |
The middle row is the one a plain list of strings would lose, since nil is what both
an absent and an empty list decode to. So the distinction is carried explicitly at
every layer it crosses — the YAML field is a pointer, the stored row carries a
Declared bit of its own, and aperture wiring show prints all three as words
((not declared), (declared empty), or the names). Collapsing declared-empty into
not-declared would silently un-enforce a slot.
Names are trimmed; an empty or repeated name is refused with
APERTURE_CONFIG_INVALID naming the slot and the key. The declaration order is
preserved, so aperture wiring pull reproduces the author's
list rather than a sorted paraphrase, and push → pull → push is a fixed point for a
slot that declares a set.
What a declaring slot refuses
This is the first of the two jobs — the rule gate. Reservation is the second, and it is applied by the registry when it merges the two layers, so the two have different failure modes and are not the same mechanism.
Enforcement is definition-time, in rule validation, and never at decision time: a
rule reading a key the slot does not declare is refused when it is saved or checked
(APERTURE_RULE_UNDECLARED_ATTRIBUTE, naming the key and the slot, shown on the rule
editor's canvas), while a rule already stored keeps deciding exactly as it did. That
covers importing a document as well as saving one rule: aperture import and the
Import rpc refuse the whole file, before anything is applied, if any rule in it
reads a key the wiring does not declare. Passing that file as --seed is refused the
same way, and the instance does not start — the check runs before the document is
applied, because a rule that reaches storage is in the shared database and every
other instance then has it. A
refusal in production, on some instances and not others, would be the very divergence
the declared set removes.
Two things a declared set never covers:
- The floor.
principal.id,principal.kindandaccount.idare stamped by the engine over every resolver's answer, so they are always readable and are never part of a declared set. Declaring them is redundant, not required. - A whole-bag read. A bare
principaloraccountwith no path reads whatever the bag carries, so it is refused outright by a declaring root rather than treated as naming nothing.
The account root is backed by one slot and is enforced when that slot declares. The
principal root is backed by two — user and machine — and principal.* resolves
to one or the other by the asking principal's kind, which validation cannot know: so
the permitted set is the union of the two, and the root is enforced only when
both slots declare. skills/attribute-providers.md has the argument for each half.
The bare-id contract
An attribute key is a bare principal id or account id — an opaque handle into the host's directory that Aperture never parses. An object id is a segmented identity. The two statements therefore differ, in both directions:
-- providers: an OBJECT provider selects the FULL IDENTITY
get_all: SELECT 'user:' || u.id AS id, u.department FROM users u -- WRONG here
-- attribute_providers: an ATTRIBUTE provider selects a BARE ID
get_all: SELECT u.id AS id, u.department FROM users u -- CORRECT
get_one: differs the same way. An object provider binds the identity's
terminal segment value (brand:42 and account:acme/brand:42 both bind
42); an attribute provider binds the bare subject id verbatim, because
there is nothing to strip. The same applies to a csv entry's id column:
alice, not user:alice.
Nothing can catch a mistake here. An identity-shaped key is a perfectly legal
opaque string: it enumerates happily, caches happily, and then matches no
principal id any fetch ever presents, because the decision path fetches by the
bare id. The slot simply never answers, and nothing anywhere complains. There is
no check any package could add — the key is opaque, so there is nothing to test
it against — which is exactly why the asymmetry is written down here, on
seed.AttributeProvider.GetAll, and in both loaders' package docs. It is also
why attribute_providers: is a separate top-level key rather than a variant of
providers:: sharing one struct would make copying a statement between them a
silent fault.
get_all: is optional, where an object provider's is required
A providers: entry must declare both statements, because an object provider
that can be fetched from but not enumerated answers List with an error, and an
errored enumeration reads as "no access" one layer up — a denial caused by a
wiring gap.
That reason does not apply to an attribute slot: attribute enumeration never
participates in scope resolution (see Attribute
providers). Omitting get_all: yields a
fetch-only slot — every decision path works unchanged, and only the
administrative listing refuses, with a coded error naming the statement to
declare. That is a feature: a host can let Aperture read the attributes of the
principal currently being decided about without exposing its whole user
table to an admin enumeration.
Building it, and the shared pool set
reg, conns, err := doc.BuildRegistryWithConnections(dir) // object providers
if err != nil {
return err
}
defer conns.Close()
attrs, err := doc.BuildAttributeRegistryWithConnections(dir, conns)
BuildAttributeRegistryWithConnections takes a *Connections rather than
opening one, and that is the point: connections: is the document's single
pool set, and the object registry and the attribute registry read through the
same pools. An entry point that opened its own would double every deployment's
connections and hand nothing back to close them. The plain
BuildAttributeRegistry(baseDir) passes no pools — correct for a document whose
attribute sources are all csv or inline — so a kind: sql entry fails there
with APERTURE_SQL_PROVIDER_CONNECTION naming the form to call, rather than
lazily on the first decision that needed the database.
Slots are filled in slot order (user, machine, account), not file order, so
a document with two bad slots always fails on the same one.
Precedence: two layers, and the shared layer wins
When both sections declare the same slot, nothing is discarded. The
attribute_providers: entry becomes that slot's shared layer, the inline
attributes: block becomes its local layer, a fetch reads their merge, and
the shared layer wins every key both serve. So an inline id the external source
lacks is resolvable — that is what the local layer is for — while an inline value
for a key the external source does serve is never read, on any instance.
A key the external source declares goes further: it is reserved to the shared
layer, so the inline block contributes nothing to it even where the external source
omits it for one row or has no record for the subject at all. That is what makes
deleting a subject from the shared directory a revocation on an instance whose own
file still lists them — see
declared_keys:. It is opt-in, and a
slot that declares nothing merges exactly as it did.
This is deliberately not the providers: / objects:
rule at slot granularity. That one really is a
discard. These two sections are not two candidates for one slot: a shared directory
the deployment administers and a block in one instance's file are two layers of
it, and refusing the second meant an instance could not add a field the directory
does not carry without abandoning the directory.
Which section is which layer is not a choice. attribute_providers: names a source
every instance of the deployment reads — a shared wiring
row projected back into that section, or a directory — while
attributes: is data written into one instance's file. If the file could override a
key the directory serves, a file on one machine would change what
principal.clearance >= 3 compares against on that machine only: the same rule,
the same grant, a different verdict, with nothing in a verdict, a trace or a note to
say which layer answered. The precedence is fixed, unconfigurable, and independent of
the order things were registered in. It mirrors the engine's floor
bag one tier down, so the three tiers
compose in one direction: floor over shared over local.
What remains refused is field-level merging with a configurable or order-dependent
winner — a rule reading a department one machine's file silently overrode is a support
ticket nobody can reproduce. A fixed winner that is the deployment-wide source makes a
contested key read the same on every instance, and
declared_keys: is the other half, in two
ways: the keys only a local layer serves are unreachable from any rule the deployment
can validate, and the keys the shared entry declares are reserved to it, so those
read the same on every instance even where the shared bag is silent.
The layering is not silent. Document.AttributeCollisions() reports the affected
slots and the caller surfaces them (aperture prints a warning) — for a different
reason than the object case. There the warning says data was discarded; here it says
which layer answers a contested key, which is what an operator debugging an
unexpected attribute value needs told and which no verdict, trace or note says. Only
slot names are reported, never keys, so the warning cannot leak a directory's
contents. Document.AttributeSlotSources() reports where each slot's bags come from —
"csv", "sql", or "inline" — naming the winner for a slot both sections fill,
so a surface that displays the wiring reads the rule instead of re-deriving it and
eventually disagreeing with it.
What is not in the file
Three things are deliberately excluded from the model state file:
- Live host domain-object metadata — that is the provider
cache: derived, disposable, never source of truth. Because
Exportreads storage back, and a provider produces no model rows, it is never reproduced. - Live subject attributes — a principal's or an account's bag is the host
directory's, for the same reason and with the same consequence:
Applywrites no row forattributes:orattribute_providers:and an export reproduces neither. The shared wiring tables hold anattribute_providers:entry's pointer to a directory; no table anywhere holds a bag. - Runtime wiring — the
connections:,providers:,objects:,field_types:,attributes:andattribute_providers:sections are runtime wiring, not model state.Applynever writes any of them to storage; insteadDocument.BuildRegistry(baseDir)— orBuildRegistryWithConnectionswhen the document declaresconnections:— turns the first four into a live*provider.Registry, andDocument.BuildAttributeRegistry(baseDir)— orBuildAttributeRegistryWithConnectionswhen an attribute source iskind: sql— turns the last two into a live*provider.AttributeRegistry. An export reproduces none of them. A declared provider names anobject_type, akind(csvorsql), optional cachettl/max_size, and then either apath(forcsv, resolved relative to the seed file) or aconnectionplusget_one/get_allstatements (forsql— see Database-backed providers), plus an optionalreferences:block (see Declaring a reference). A malformed entry isAPERTURE_CONFIG_INVALID/APERTURE_PROVIDER_INVALID, and a broken connection declaration isAPERTURE_SQL_PROVIDER_CONNECTIONorAPERTURE_SQL_PROVIDER_DSN_LITERAL. Whenproviders:andobjects:claim one type,providers:wins the type outright — see When both sections claim a type.
The file is not the only home for wiring
"Apply writes no wiring" and "an export reproduces no wiring" are both still true,
and neither means the seed file is the only place a deployment's wiring can live.
Four of the six wiring sections are shared, and aperture wiring push writes
them to the five apt_wiring_* tables
every instance of a deployment already holds:
| Section | Home | Read back by |
|---|---|---|
connections: | shared — the manifest of names, and nothing else | aperture wiring pull |
providers: (with its references:) | shared | aperture wiring pull |
field_types: | shared | aperture wiring pull |
attribute_providers: | shared | aperture wiring pull |
objects: | local to the instance whose file lists it | nothing |
attributes: | local to the instance whose file lists it | nothing |
The line between the two halves is a pointer to data versus the data itself. A
providers: or attribute_providers: entry says where to read metadata or an
attribute bag from, which is a fact about the deployment and safe to copy to a second
instance. An objects: or attributes: entry carries the metadata or the bag, and
Aperture's own database is never the source of truth for a host's domain data — the
same Non-Goal that keeps the provider cache out of an export.
Three read-backs therefore exist and answer three different questions. Export
emits the model and no wiring, and is reachable over Twirp with an admin-tier
token. aperture wiring pull emits the four shared wiring sections and no
model, is CLI-only, and is gated by the store credential alone. Nothing emits the
two local sections, because nothing put them anywhere but the file.
A pushed row carries no secret and no path — not a DSN, not a credential, not
even the dsn_env: variable name, and no filesystem path — because a row is copied
to a second instance that resolves its own credentials and has its own disk. That is
why kind: csv is refused at the push and stays perfectly legal in a local file.
The whole contract, including what an instance does when both the database and its
file declare the same entry, is on aperture wiring and in
Two instances, one store.
Related
- The RBAC model — the entities the document mirrors.
- Rules engine — the canonical AST a rule's
astfield carries. - Providers — the registry
providers:wiring builds, and the cache that is never exported. - Storage — the
StoragebackendApplywrites through andExportreads back. - Portability CLI — the command surface over import/export.
aperture wiring— the command surface over the four shared wiring sections.- Two instances, one store — the topology the shared sections exist for.