Decisions
Audience: operators and integrators asking and auditing access-control questions from a shell.
The decision commands are the read-only core of the CLI. They never change the
model — they ask a question of it. All three of check, enumerate, and
explain take the subject principal as a positional argument (the principal
the question is about), not a --principal flag; see
Global options for why the
write commands differ. identifiers inspects an object type's source.
All four build the same decision stack aperture serve does, so a question asked
in a shell resolves exactly as it does over HTTP — see
Rule-backed permissions.
check — decide one question
aperture check [options] <principal> <action> <object>
check prints a one-word verdict (allow / deny) and a reason, and carries
the verdict in its exit code — 0 for allow, non-zero for deny — so it
composes in a pipeline (aperture check … && deploy).
bin/aperture check alice read account:acme/project:atlas/document:42
allow
reason: allowed by grant g-eng-read-atlas (allow account:acme/project:atlas/**) at specificity 39300; 1 matching grant(s) considered
A question with no matching grant is always a deny — Aperture fails closed:
bin/aperture check bob write account:acme/project:atlas/document:42
deny
reason: default deny: no grant matched action "write" on "account:acme/project:atlas/document:42" for principal "bob" in account "acme"
Full flags: check.
Rule-backed permissions decide the same here as on the server
check, enumerate, identifiers and explain build the same decision
stack aperture serve does: the object metadata declared in the seed's
providers: and objects: sections, the rules engine over the stored rules, and
scope resolution. A permission whose scope strategy is rule-backed
(inclusive;rule=… / exclusive;rule=…) is therefore evaluated identically from
the shell and over HTTP.
This was not always true. Before the shared stack landed, the one-shot commands
wired no rules engine, so a rule-backed permission had no evaluator, the scope
resolver reported APERTURE_SCOPE_RULE_UNCONFIGURED and the fail-closed policy
turned that into a deny. If you scripted against the old behaviour, those checks
now return the verdict the rule implies — which may be allow.
explain — why a decision resolved
aperture explain [options] <principal> <action> <object>
explain takes the same three arguments as check and prints the whole
decision trace: the subject set, every grant considered, why each did or did not
apply, and the deciding grant (marked *). It is a first-class operation, not a
debug afterthought.
bin/aperture explain alice read account:acme/project:atlas/document:secret
Explain alice/read on account:acme/project:atlas/document:secret in account acme
subjects: principal:alice, role:editor, group:engineering
grants considered (3):
g-eng-read-atlas [allow account:acme/project:atlas/**] allow covers the object via literal scope at specificity 39300
g-editor-write-atlas [allow account:acme/project:atlas/**] action "write" does not match the requested "read"
* g-deny-secret-read [deny account:acme/project:atlas/document:secret] deny covers the object via literal scope at specificity 60300
verdict: DENY (top specificity 60300)
reason: denied by grant g-deny-secret-read (deny account:acme/project:atlas/document:secret) at specificity 60300; 2 matching grant(s) considered
When a grant's scope is decided by a rule, the trace also carries the evaluation notes that rule recorded — a metadata field read with the wrong shape, or a match that happened only because the field was absent:
evaluation notes (1):
g-viewer-read [rule public-documents]: object.tags: expected collection, got string
Notes are diagnostic only; they never change a verdict, and they name paths and shapes, never metadata values.
Full flags: explain.
enumerate — list objects a principal may act on
aperture enumerate [options] <principal> <action> <pattern>
enumerate turns the question around: instead of one object, it lists the
object ids under a <pattern> that the principal may take <action> on, one id
per line. --limit caps the result count for this request, within the
deployment's own ceiling — see
--limit and the deployment's ceiling.
Enumeration expands objects from the
object sources the model declares — providers: (a file- or database-backed
provider per type) and objects: (metadata declared inline) — so a model with
neither (like the embedded example) yields an empty list. Run enumerate against
a seed that declares an object source for the type.
bin/aperture enumerate alice read 'account:acme/project:atlas/document:*' \
--seed ./model-with-providers.yaml --limit 100
--limit and the deployment's ceiling
Two numbers bound one enumeration, and they are not the same kind of thing:
| Set by | Governs | Asks | |
|---|---|---|---|
| Request limit | --limit | this one command | how many ids you want back |
| Deployment ceiling | --enumerate-limit / APERTURE_ENUMERATE_LIMIT, default 1000 | the whole process | how many ids anyone may ask this deployment for |
The ceiling always wins:
- A
--limitabove the ceiling is clamped down to it. - A
--limitat or below the ceiling is honoured as asked. - A
--limitof zero or less asks for the ceiling. It does not mean "unlimited", and there is no spelling that does —enumeratenever materialises an unbounded set.
# ask for 100 of the deployment's 1000
bin/aperture enumerate alice read 'account:acme/project:atlas/document:*' \
--seed ./model.yaml --limit 100
# ask for 5000 against the default ceiling: you get 1000, not 5000
bin/aperture enumerate alice read 'account:acme/project:atlas/document:*' \
--seed ./model.yaml --limit 5000
# raise the ceiling, then ask for all of it
bin/aperture enumerate alice read 'account:acme/project:atlas/document:*' \
--seed ./model.yaml --enumerate-limit 2000 --limit 2000
The ceiling is the same number aperture serve honours — it configures the
process, not the command — so the CLI and the server can never be bounded
differently. check, explain, identifiers and mcp carry the same flag for
that reason. See
The deployment's enumeration ceiling.
A full-looking result may be a truncated one. Nothing in the output marks the
difference: a list that stops at the ceiling looks exactly like a population that
happened to end there. When a result comes back holding exactly its effective
bound the engine logs a WARN naming that bound, so an unexpectedly round result
is a reason to check the log and re-ask with a higher --enumerate-limit. The
warning is a hint, not proof — a complete set of that size raises it too.
--limit is not the cheap knob. It shortens only the second half of the
work: the candidate set is gathered against the ceiling first, and --limit
then caps what survives the access decision. --limit 10 against a ceiling of
1000 still pays for ~1000 candidates. The number that moves the cost is
--enumerate-limit, and Performance
carries the measured price of raising it.
A value for --enumerate-limit that is not a whole number greater than zero —
banana, 0, -5 — fails the command with APERTURE_CONFIG_INVALID naming the
setting and the value it rejected, before any store is opened. To get the
default, omit it.
Narrowing by object metadata
--field and --fields-json filter the listing by the objects' metadata —
"which of the datasets I may list carry brand Y?". They apply on top of the
access decision, so they can only ever remove objects from the list; an object
a check would deny is never returned however the predicate is written.
The predicate is typed: a field matches only when its value equals the wanted
value and is of the same kind, so the string "5" never matches the number
5. A shell flag only carries a string, and guessing which strings are "really"
numbers would make enumerate return objects check then denies — so there are
two flags rather than one, and each says what it sends:
| Flag | Sends | Notes |
|---|---|---|
--field key=value | always a string | Repeatable. Everything after the first = is the value, so --field expr=a=b wants "a=b". |
--fields-json '{…}' | a JSON object — real numbers, bools, lists | Use it when the metadata value genuinely is not a string. |
Both may be given. --fields-json is merged first and --field entries
then override it by key, so a stored JSON body can be reused with one value
swapped from the shell.
# a string field
bin/aperture enumerate alice list 'account:acme/**' --seed ./model.yaml \
--field tier=premium
# a list field, matched by MEMBERSHIP — "datasets carrying brand Y"
bin/aperture enumerate alice list 'account:acme/**' --seed ./model.yaml \
--field brands=brand:Y
# seats is a NUMBER, so it needs the JSON spelling; --field seats=5 matches nothing
bin/aperture enumerate alice list 'account:acme/**' --seed ./model.yaml \
--fields-json '{"seats":5,"active":true}'
# merged: seats from JSON, tier overridden from the shell
bin/aperture enumerate alice list 'account:acme/**' --seed ./model.yaml \
--fields-json '{"seats":5,"tier":"basic"}' --field tier=premium
The rules the listing obeys:
- Predicates are ANDed — every one must hold.
- A list-valued field matches by membership; a whole list in
--fields-jsonis a container compared by equality. - A field the object does not carry never matches — not even against
null. - Filtering happens before
--limit.--field tier=premium --limit 10gives the first ten premium objects, not the premium ones among the first ten candidates.
A malformed predicate is a usage error (APERTURE_INVALID_INPUT) naming the
offending text — a --field with no =, an empty key, a --fields-json that is
not JSON or is JSON but not an object. It is never silently skipped: a dropped
predicate would widen the result, and a filter that silently widens is a filter
that authorizes. Parsing happens before the store is opened, so a usage error
never boots a decision stack.
Filtering needs an object source for the type, exactly as enumeration itself
does. A model that declares none reports APERTURE_PROVIDER_UNREGISTERED rather
than printing nothing — an empty list would read as "no access". (Because the
predicate is applied per candidate, you only see that error once the principal is
allowed at least one object under the pattern.)
Restricting to what a reference names
--via asks the other direction. --field asks "which datasets contain
brand Y?"; --via asks "which brands does dataset X list?".
bin/aperture enumerate alice read 'account:acme/brand:*' --seed ./model.yaml \
--via account:acme/dataset:x.current_brands
It restricts the listing to the identities held in a declared reference field
on one holder object — a references: block in the seed document
(Declaring a reference) is what makes
the field dereferenceable.
The two are not interchangeable, and the asymmetry is the reason --via
exists. The dataset holds current_brands, so a predicate on dataset expresses
"which datasets contain brand Y?" — that is --field. A brand holds no field
naming its datasets, so no predicate on brand can express "which brands belong to
dataset X?" at all. --field is a filter; --via is a dereference.
The spelling is <holder-identity>.<field>, split on the last . — a . is
legal inside an identity component (dataset:2026.q1) while a reference field is
a single metadata key, so the final dot is the only unambiguous boundary. The
holder's type is deliberately not spelled: it is the identity's last segment
type, which the engine derives itself, so the two cannot disagree.
The rules the restriction obeys:
- Repeatable, and edges are ANDed. Two
--viaflags give "the brands in dataset x and in campaign spring". - It composes with
--field, and both apply before--limit. - It only subtracts. Restriction runs on candidates that still go through the
access decision, so
--viacan never surface an objectcheckwould deny. - Exactly one hop. The brands a dataset names are not themselves
dereferenced, however many references
branddeclares.
A holder you may not read prints nothing and exits 0. That is deliberate, not
a bug: "you may not see dataset X" and "dataset X lists nothing you may see" must
not be tellable apart, or --via becomes a way to probe for objects you were
never allowed to know about. The same goes for a holder in another account —
empty whether or not it exists — and for a principal who is not a member of
--account.
An absent holder is APERTURE_NOT_FOUND only when it is inside --account
and the principal is a member of it. That is the ergonomics a typo deserves,
confined to someone already inside the account.
A wiring fault stays loud rather than printing nothing: a field with no
references: declaration is APERTURE_PROVIDER_REFERENCE_INVALID, and a holder
type with no provider is APERTURE_PROVIDER_UNREGISTERED. A malformed --via —
no ., an empty holder, an empty field — is APERTURE_INVALID_INPUT naming the
offending text, rejected before the store is opened.
If a --via returns nothing you expected, check the server log first: a
referenced identity the provider no longer serves is skipped with a warning
naming it, and the commonest cause is an identity composed in the wrong shape
(brand:1 where the deployment yields account:acme/brand:1).
Full flags: enumerate.
search — find an object by the name a person types
aperture search [options] <principal> <action> <pattern> <query>
search ranks every object under <pattern> that <principal> may take
<action> on by how well its metadata matches <query>, best match first. It is
the name-to-id lookup a surface does when a question arrives in a person's own
words.
bin/aperture search alice read 'account:acme/brand:*' nike --seed ./catalogue.yaml
bin/aperture search alice read 'account:acme/brand:*' nike --in label --limit 5 --scores
Every result is one check would allow: candidates are decided before they
are scored, by the same walk enumerate uses, so the output is always a subset
of what enumerate returns for the same principal, action and pattern. A score
ranks; it authorizes nothing.
Matching is case- and punctuation-insensitive (Nike, Inc. matches nike inc)
and tolerates a typo or a transposition on tokens long enough for one to be
unambiguous. Only text is matched — a string field and the string elements of
a list field; match a number, bool or date with --field instead.
Aperture has no notion of a "label": a label is an ordinary metadata field whose
name your host chose. Every field holding text is searched by default; --in
names the fields to search, and --scores prints the score plus the field and
value that produced each match — which is what tells a hit on an alias apart
from a hit on a display name.
account:acme/brand:3 0.875 label=Nike, Inc.
account:acme/brand:1 0.800 label=Nike Air Max Collection
--field / --fields-json and --via mean exactly what they mean on
enumerate, and they compose with the query: the predicate and the reference
edge narrow the candidate set, the query ranks what is left. "The brand called
Nike in dataset X" is one command.
--min-score drops weak matches (default 0.4). Raising it narrows the
shortlist; it never widens what the principal may see.
--limit caps how many matches come back — the top of a finished ranking,
not a bound on the scan, so you get the best N rather than the first N found. The
scan itself runs to the deployment's --enumerate-limit ceiling; a scan that
fills that ceiling is logged, because a better match may lie past it.
Because it needs object metadata to match against, search errors with
APERTURE_PROVIDER_UNREGISTERED against a model that declares no object source —
an empty list would read as "you may see nothing" and hide the misconfiguration.
Full flags: search.
identifiers — a type's valid instance ids
aperture identifiers [options] <object_type>
identifiers lists every valid instance id of an object type, read from the
object source the model binds to that type — a providers: entry (a CSV file
today, a data source later) or inline objects: metadata. --exclude drops ids
from the result — this is how an exclusive "all except these" allowance expands
into a positive allow-list. Because it needs a source, identifiers errors with
APERTURE_PROVIDER_UNREGISTERED against a model that declares none, so run it
against a seed that binds the type:
bin/aperture identifiers document --seed ./model-with-providers.yaml
bin/aperture identifiers document --seed ./model-with-providers.yaml --exclude secret
Full flags: identifiers.
Related
- Global options —
--seed/--store/--account, and why the principal is positional here. - First decision (CLI) — the same commands walked through against the example model.
- Mutations — change the grants these decisions read.
- Performance & the NFR — what the deployment's enumeration ceiling costs, measured.
- Command-Line Reference — the generated flag tables.