18. Role permissions for agent edits
Role permissions for agent edits: which role may change which subtree, asked before the change
Exercises allow (the deciding entry named as a path into the role model, --at, --format json, exit classes), a close()d role vocabulary, the allow-then-set loop, an agent skill, why on the rule
Rendered from
use-cases/18-role-permissions/README.md
in the engine repository. The models, the expected output and the
check.sh that drives the CLI over all of it are in
use-cases/18-role-permissions/.
Scenario
A fleet of agents edits one service model, and each agent operates
under a role: dev scales services and deployments, product flips
feature flags and rewrites a service’s description, qa tunes the
test configuration, admin may change anything. The question an
agent has to ask before every change is which subtrees its role may
modify, and the answer has to come from a document a person wrote and
reviewed, not from the agent’s reading of a wiki page.
Here that document is roles.aon, a role model that is itself aontu:
one entry per role, each naming the subtrees the role may change
(allow) and the ones it may not (deny), as the paths get and
set already spell. aontu allow --role dev roles.aon <path>...
answers before the change is made, with an exit code an agent
branches on and a report that names the entry that decided each
path. The agent’s skill, skill/SKILL.md, is the loop: ask, and run
aontu model set only on exit 0. The model being governed, model.aon,
holds its own vocabulary and constraints, so a change the gate allows
is still refused by set when it names a key the model does not
declare or breaks a bound. The gate says who; the model says what.
The model tree
model.aon is the document the roles govern. services carries
three services, each an instance of the Service vocabulary (owner,
tier, replicas, description); deploy carries two regions, each with
a replica count per service and a canary beside it; features holds
the flags and tests the smoke and regression settings. Service is
type()-marked, so it constrains every service and stays out of the
output.
$
├── Service
│ ├── description string
│ ├── owner string
│ ├── replicas integer&min(1)
│ └── tier "critical"|"standard"
├── deploy
│ ├── eu1 (3)
│ └── us1 (3)
├── features
│ ├── checkout_v2 false
│ ├── dark_mode true
│ └── new_search false
├── services
│ ├── auth (4)
│ ├── billing (4)
│ └── search (4)
└── tests
├── regression (2)
└── smoke (2)
aontu view doc --depth 2 model.aon draws it, and check.sh pins it
with --out --check. A key with (n) after it is a container the
depth bound stopped at, and n is how many keys are not drawn; a
leaf carries its canon, which is the kind of thing it is rather
than its value.
Layout
| file | carries |
|---|---|
roles.aon | the role model: Role, a type()-marked close()d vocabulary of desc, allow and deny?; the close()d registry of admin, dev, product and qa |
model.aon | the governed model: the Service vocabulary, three services, two regions with a canary each, the flags and the tests; every value a role may change is a default (*3), so the model accepts a change to any of them and the role is what decides who |
changes.aon | the agents’ overlay, written by aontu model set and never by hand |
system.aon | model.aon plus changes.aon: the served view, where get and why run |
policy.aon | a policy document that keeps its roles under $.policy.roles, for --at |
skill/SKILL.md | the agent skill: ask the gate with the arguments set will get, and branch on the exit code alone |
proposals/ | edits to the role model that do not stand up: a key the vocabulary does not declare, an allow list written as a string, a fifth role |
expected/ | JSON goldens for the model, the role model and the served view after the loop; the allow reports as text and JSON; the model tree, text and SVG |
How the model is designed
Quoted output below is trimmed of ANSI codes and machine-absolute path prefixes.
- The role model is aontu, and the language checks it.
Roleistype(close({ desc: string allow: [&: string] deny?: [&: string] })), androlesisclose({ &: $.Role ... }), so an entry with a key nobody declared, an allow list that is a string, or a role the registry does not carry is a located error at the moment the gate evaluates the model, and the gate answersverdict: error, exit 4, with the engine’s own finding. The model meets the shapeaontu allowneeds, a spread over the registry,roles: { &: { allow: [&: string] deny?: [&: string] } }with every entry held to start at$, which is why aclose()d vocabulary has to declaredeny?itself: without it the shape’s optional key is the first thingclose()refuses. - An allow entry covers itself and everything below it, and never
the map above it.
productmay change$.featuresand so$.features.checkout_v2; it may not change$or$.services, because a change at a map reaches every sibling in it. Only an entry that names the root covers the root, which is whatadminhas. - A deny entry refuses every path that intersects it.
devis allowed$.servicesand denied$.services.*.tier, so$.services.auth.tieris refused, and so are$.services.auth,$.servicesand$: a change at any of those could rewrite the tier from above. A path below the denied node is refused too, and a sibling such as$.services.auth.replicasis untouched. Deny wins over allow whatever the order the entries were written in, so the registry reads as a grant with carve-outs rather than as a rule list with a precedence. *matches exactly one key.$.deploy.*.replicasreaches$.deploy.eu1.replicasand everything below it, sodevmay set any service’s count in any region. It does not reach$.deploy.eu1(the map above), and it does not reach$.deploy.eu1.canary.replicas.search, because the star tookeu1and the next segment iscanary, notreplicas.- The gate reads the role model alone.
aontu allownever opensmodel.aon: it answers a question about paths, from the entries, and a path the model does not have is answered the same way as one it does. What the model says about a value isset’s business, which is whycheck.shruns the two in sequence rather than folding them together. - The answer names the deciding entry as a path into the role
model.
refused by $.roles.dev.deny.0 ($.services.*.tier)is a path and the entry’s text, soaontu model why '$.roles.dev.deny.0' roles.aonnames the file and the line that wrote the rule, and a reviewer opens the model at the entry rather than searching for the string. - An undeclared role may change nothing. The registry is
close()d, so a role can only be added by editingroles.aon; a role the gate is asked about and cannot find is refused with ano_pathfinding at its path, carrying the nearest declared name when one is close (did you mean dev?). - The governed values are defaults, so the gate is the decision.
replicas: *3andtier: *"critical"accept a concrete value from the overlay, which is what makes a refused proposal a role decision rather than a model one: a dry run of the refused tier change isverdict: valid. The vocabulary still holds what a value may be:Serviceisclose()d andreplicasisinteger & min(1), soadminis allowed$.services.auth.colourby the gate and refused bysetwith[aontu/closed], anddevis allowed$.deploy.us1.replicas.auth=0and refused with[aontu/constraint]. - The assignment spelling is accepted. The gate takes
'$.services.auth.description="Sign-in, sessions and MFA"'as it is, so the skill hands it the very arguments the write will get and the two cannot drift. --atmoves the roles map.policy.aonkeeps its roles under$.policy.roles, beside an owner and a review rule; asked with--at '$.policy.roles'the gate answers from there and names the deciding entry under that anchor, and asked without it the gate looks at$.roles, finds no role, and refuses.
A mixed question from dev, refused as soon as one path is, with
every path answered:
$ aontu allow --role dev roles.aon $.services.auth.replicas $.deploy.eu1.replicas.search $.services.auth.tier $.services.billing $.features.dark_mode
verdict: refused
role: dev
$.services.auth.replicas: allowed by $.roles.dev.allow.0 ($.services)
$.deploy.eu1.replicas.search: allowed by $.roles.dev.allow.1 ($.deploy.*.replicas)
$.services.auth.tier: refused by $.roles.dev.deny.0 ($.services.*.tier)
$.services.billing: refused by $.roles.dev.deny.0 ($.services.*.tier)
$.features.dark_mode: refused (no allow entry of dev covers it)
$.services.billing is refused by the tier deny although billing’s
tier was never named: a change at the service could rewrite it. The
deciding entry is a path, and why locates the rule:
$ aontu model why $.roles.dev.deny.0 roles.aon
$.roles.dev.deny.0 = "$.services.*.tier"
1. string roles.aon:10:60 (spread)
2. "$.services.*.tier" roles.aon:20:12
A role nobody declared:
$ aontu allow --role ops roles.aon $.features.dark_mode
verdict: refused
role: ops
$.features.dark_mode: refused (role ops is not declared)
$.roles.ops: no_path [reference]
The role ops is not declared at $.roles in this document.
An allowed change, landed by the loop in skill/SKILL.md, and read
back from the served view with its provenance:
$ aontu model set '$.services.auth.replicas=5' --entry model.aon --overlay changes.aon
verdict: valid
wrote: changes.aon
$ aontu model why $.services.auth.replicas system.aon
$.services.auth.replicas = 5
1. 5 changes.aon:2:33
2. *3 model.aon:18:13 (pref)
A role-model edit that does not stand up. The gate decides nothing and answers with the engine’s own finding, at the line of the proposal:
$ aontu allow --include-root . --role dev proposals/role-unknown-key.aon $.services.auth.replicas
verdict: error
role: dev
$: closed [reference]
[aontu/closed]: Cannot resolve value at path $.roles.dev.scope
Cannot add to closed structure. The map or list is closed and does not accept new keys/elements.
Cannot resolve value: "global"
--> proposals/role-unknown-key.aon:6:20
4 | # model that was to decide does not stand up.
5 | @"../roles.aon"
6 | roles: dev: scope: "global"
^ value was: "global"
7 |
8 |
What check.sh proves
check.sh drives the CLI end to end and asserts every outcome: exit
codes, error and reason codes grepped from the reports, and generated
documents diffed against the expected/ goldens. The loop runs on a
temporary copy, so the committed changes.aon is never written.
model.aonevaluates toexpected/model.jsonandroles.aontoexpected/roles.json; thetype()-markedServiceandRolestay out of both.- An allow entry covers itself and everything below it:
productis allowed$.featuresand$.features.checkout_v2by$.roles.product.allow.0, andqais allowed$.tests.smoke.timeoutby$.roles.qa.allow.0. - It never covers the map above it:
productat$.servicesandqaat$are refused as uncovered, andadminis allowed$and$.services.auth.tierby$.roles.admin.allow.0, the one entry that names the root. *matches exactly one key:devis allowed$.deploy.eu1.replicasand$.deploy.us1.replicas.searchby$.roles.dev.allow.1, and refused$.deploy.eu1,$.deployand$.deploy.eu1.canary.replicas.searchas uncovered.- A deny entry refuses every path that intersects it:
devis refused$.services.auth.tier(the node),$.services.auth,$.servicesand$(above it) and$.services.auth.tier.level(below it, a path the model does not have) by$.roles.dev.deny.0, and$.services.billing.ownerby$.roles.dev.deny.1;$.services.auth.replicasbeside the denied node is allowed. - Deny beats allow whatever the order: a scratch model with the deny
written before the allow refuses
$.a.bby$.roles.r.deny.0and allows$.a.cby$.roles.r.allow.0. - An uncovered path is refused with
no allow entry of product covers it, and a role with no allow list (roles: r: {}) allows nothing. --role opsis refused, exit 1, and the report matchesexpected/allow-ops.txt: every pathrefused (role ops is not declared)and ano_pathfinding at$.roles.ops;--role devecarriesnote: did you mean dev?.- The assignment spelling is accepted:
productasked with'$.services.auth.description="Sign-in, sessions and MFA"'is allowed by$.roles.product.allow.1; a value that carries a second pair ("ok" services: auth: tier: "critical") is refused as usage, exit 2, becausesetwould write it as a second change. - The mixed
devquestion above matchesexpected/allow-dev.txt, exit 1. --format jsonforqaat$.tests.smokeand$.servicesmatchesexpected/allow-qa.json(compared without the version line of the producer block), with"verb": "allow"and"reason": "uncovered", exit 1.--at '$.policy.roles'overpolicy.aonallowsdev$.services.auth.replicasby$.policy.roles.dev.allow.0and refuses$.services.auth.tierby$.policy.roles.dev.deny.0;releaseis allowed$.deploy.eu1; asked without--at,devis refused as undeclared withno_pathat$.roles.dev.- The loop lands an allowed change:
devasks$.services.auth.replicas=5, the gate is exit 0,setisverdict: valid, the line is inchanges.aon,getonsystem.aonanswers5, andwhynameschanges.aon:2:33over the model’s*3. - The loop stops a refused change:
devasks$.services.auth.tier="standard", the gate is exit 1 and names$.roles.dev.deny.0,setis never run, the overlay has notierline and the served view still says"critical"; a--dry-runof the same write isverdict: valid, so the model alone would have taken it. - The other roles run the same loop:
product’s description (in the assignment spelling),qa’stimeout=60anddev’s$.deploy.eu1.replicas.search=6land and read back;productasking$.services.auth.replicas=9is uncovered andopsis undeclared, neither reachesset, and the served view is unchanged by either. - A change the gate allows is still held to the model:
adminis allowed$.services.auth.colour="blue"by$andsetrefuses it with[aontu/closed];devis allowed$.deploy.us1.replicas.auth=0andsetrefuses it with[aontu/constraint]againstmin(1). Neither reaches the overlay. - The served view after the loop matches
expected/system.json, and the overlay carries exactly the four allowed lines. - A broken role model is
verdict: error, exit 4, with the engine’s own finding and no path decided:proposals/role-unknown-key.aonis[aontu/closed]at$.roles.dev.scope,proposals/role-allow-string.aonis[aontu/list]at$.roles.qa.allow,proposals/add-undeclared-role.aonis[aontu/closed]at$.roles.ops, and a scratch vocabulary thatclose()s withoutdeny?is[aontu/closed]at the shape’sdeny. why '$.roles.dev.deny.0' roles.aonprints the entry and namesroles.aon:20:12, the line that wrote the rule.- The model tree above matches
expected/diagram-doc.txt, and--out --checkholds both the text and the SVG.
Running it
./check.sh, from anywhere; set AONTU= to point at another build.
Every step prints a numbered line, and the script stops at the first
failure. The proposals include roles.aon from one directory up, so
run them from the case directory with --include-root ., as
check.sh does. The gate’s two moves, by hand:
aontu allow --role dev roles.aon '$.services.auth.replicas=5' # ask
aontu model set '$.services.auth.replicas=5' --entry model.aon --overlay changes.aon # then write
The how-to guide Gate changes by role
walks the recipe for a model of your own, and the reference section
aontu allow states every
rule, exit code and limit of the verb.