Who may do what, written down.
Access control in loomcycle: rule lists, groups, bindings
1. Where authority used to live
Until v1.122, authority in loomcycle lived on the token. A token carried scopes (runs:create, substrate:tenant, substrate:admin) and, optionally, a list of agents it was allowed to start. That was enough while one person, or one service, held one token and did one job.
It stopped being enough when we created Teams/Slack/Discord/Telegram bridges where ysers may come from groups and all require authorization and access control. Everyone shares the same agents, the same search, the same chat surface.
Token scopes could not say any of that. A scope names a kind of thing, never an instance. It can allow, never deny. And no scope lets a hundred users share one definition of what they may do.
So an operator could not write a sentence as plain as this one: the support group may run chat/* agents but not admin/*, may read the shared-ro volume but not write it, and never gets Bash.
v1.122 makes that sentence writable.
2. The shape of a rule
Access control in loomcycle has three objects: rule lists, groups and bindings. Here is the sentence above, as config:
acls:
support:
rules:
- { effect: allow, actions: [run], resources: ["agent:chat/*"] }
- { effect: allow, actions: [read], resources: ["volume:shared-ro"] }
- { effect: deny, actions: [invoke], resources: ["tool:Bash"] }
groups:
support: { tenant: acme, members: [alice, bob] }
acl_bindings:
- { acl: support, group: support, tenant: acme }
A rule is {effect, actions, resources, when}. Four fields, no more.
Resources cover every primitive a principal can reach: agents, volumes, tools, skills, mounted MCP and A2A tools, memory, SQL memory, documents, history, channels, definition families, teams, credentials, crypto, models, tiers, the gateway, runs, principals, transports and git remotes. Selectors are /-globs (agent:chat/*, agent:chat/**), scoped names (memory:tenant, document:tenant:/kb/**, def:agent/chat/*) or a small enum such as run:own. acldef op=catalog lists every resource type, verb and scope, so nobody has to guess.
when is a closed set of conditions: transport, time of day and week with a time zone, whether the run is interactive, model tier, user tier, source CIDR, owner_is_self, the agent, and spawn depth. A condition the request cannot answer never holds. That one line matters more than it looks: a rule meant for "interactive sessions only" does not quietly apply to a scheduled run because the scheduler had no opinion about interactivity.
An explicit deny beats any allow, and order never matters. There is no first-match-wins list to keep in your head, no rule position that silently changes the outcome when someone inserts a line above it. If any rule that reaches you says no, the answer is no.
An explicit deny beats any allow, and order never matters.
Parsing is strict. An unknown field, an unknown type, an unknown verb: refused at load, not ignored at run time. A typo in a rule list fails loomcycle validate and the boot, which is where you want to find it.
3. Groups and bindings
Groups nest, at most 8 levels deep. A cycle or a self-membership is refused. In the reference system, that means "readers of board papers" can contain "board members" and "board secretariat" without anyone copying a member list twice.
A group can carry an external_id. Such a group belongs to an identity provider: its members are synced from there, so adding or removing members by hand, or upserting it, is refused with 409 group_external. Deleting it is allowed. The source of truth stays where it is.
Bindings attach a rule list to a user, a group, a token or a whole tenant, with an optional expiry. A contractor gets read access to one document subtree until the end of the quarter, and on the first day of the next quarter the binding has simply stopped applying. A binding to a list that does not exist, or with an expiry already in the past, is refused with 400 acl_binding_invalid rather than accepted and silently inert.
A principal's effective rules are compiled from its groups (transitively), every binding that reaches it, and the built-in list of its token class. The result is cached until the tenant's acl_version moves. Every write invalidates the caches on every replica over the cluster bus, and LOOMCYCLE_ACL_CACHE_TTL_SECONDS (default 5) bounds the lag if a bus message is ever lost.
4. How do you turn this on without breaking anything?
This is the question that decides whether access control gets adopted or gets postponed for a year. A deployment already has users, tokens, schedules and agents doing real work. Nobody wants to flip a switch and discover which of them just lost their access.
So the rollout has three modes, set by LOOMCYCLE_ACL:
compat(the default): the old scope gates decide exactly as before. The one addition is that an explicit deny bound to the caller is honoured on top. Writing a deny is safe from day one.shadow: the scope gates still decide, and the new engine evaluates every question beside them. Every disagreement is logged and audited asacl_shadow_diverged. Nothing changes for the caller.enforce: the engine decides. A question no rule allows is refused403 acl_denied. A request whose rules could not be read is refused503 acl_unavailable, never admitted.
The piece that makes enforce safe is the set of built-in lists. Each token class carries one that reproduces its reach today: builtin:admin, builtin:tenant-operator, builtin:member, builtin:isolated, and builtin:run-plane for a token limited to some agents. So enforce with no lists written behaves like compat.
That claim is held by a test, not by a promise. For each token class, on every HTTP route, gRPC RPC and MCP tool, in both compat and enforce, the built-in list admits wherever the scope gates admit, and at the widest token of a class it reaches nothing the gates refuse. Writing that test corrected the lists in several places before anyone ran them in production.
Shadow mode got sharper in v1.123. It used to report only request-level divergences, so the preview missed what the rules would do inside a run. Now each narrowing that enforce would apply at run start is reported too: a tool withheld, a volume made read-only or dropped, a memory scope dropped, a skill denied, a spawn agent: that would be refused. Each one is audited with the run id, once per question per run tree. And shadow no longer applies explicit skill denies, which contradicted the one thing shadow promises: it changes nothing.
Shadow mode shows you tomorrow's refusals today, while every caller still gets yesterday's answer.
5. Inside a run
A request gate is the easy half. The harder half is what happens after a run starts, when an agent is choosing tools, opening volumes and spawning sub-agents on its own.
Inside a run, the agent's grants are intersected with the caller's rules:
- A tool the caller may not
invoke, built-in or mounted, is withheld from the model. It is reported once at run start as acapability_inertevent with gateacl, and the/runterminal says it in plain words: "Bash withheld by your access rules". - A volume the caller may not
writeis bound read-only. One it may notreadis dropped. - Memory, SQL memory and history scopes the caller may not
readare dropped, and skill denies are added to the agent's skill patterns. - The
Agenttool asksspawn agent:<child>before a child starts, and the caller's rules bind every sub-agent down the tree.
The agent definition says what an agent can do. The caller's rules say what this caller may have done on their behalf. The run gets the smaller of the two.
Specific tools also ask about the instance a call names, on every transport and inside a run. Definitions in all 12 families ask read, author or retire of the definition the call names. Document asks of document:<scope>:<path> at every Path-tree name the document is filed under. Path asks of whatever each entry names. CredentialDef asks use or manage. Listings filter instead of refusing: a search, a directory listing or a credential list simply leaves out what the caller may not read, so a denied document never shows up as a count, a title or a gap.
v1.124 closed one seam in that last rule. Since v1.123, a tenant-scope Memory search can return tenant documents, and those document hits had skipped the document checks Document search applies. A caller denied a path could read it through Memory. Now every Memory hit that lives in a document is checked exactly as Document search checks it, and dropped when the caller may not read it. A caller whose rules cannot deny any document reading gets the same answer as before, with no extra lookups.
6. Rules that survive a pause
Runs in loomcycle do not always finish in one sitting. They are paused at shutdown and resumed after restart, restored from snapshots, recovered after a crash, queued until a provider slot frees up. If the rules applied only to live requests, every one of those paths would be a way around them.
So a run keeps its caller's rules. Its config record carries an acl_origin: tenant, subject, token definition id, token class, transport. Never a bearer and never the compiled rules. The rules are compiled again when the run resumes, which means a binding written during the pause holds after it, and a queued run that a new deny reached while it waited is refused when its turn comes. When the recorded caller's rules cannot be read at resume time, the run stays paused or queued, reported acl_unavailable, and a later pass starts it.
Triggered runs had a related gap. A run started by a schedule, an inbound webhook or an A2A peer had no caller, so it had no caller rules. Anyone who could author a trigger could step around a deny bound to them.
Now ScheduleDef and WebhookDef rows carry a server-set acl_owner, stamped at create and fork, after the overlay, so an overlay cannot set it. At fire time the owner's current rules are compiled and the run is narrowed by them. A team delivery carries the owner's rules down the whole walk. A deny is audited; a schedule records the fire as failed, a webhook answers 403. An A2A run is bound to the peer's own credential. Team walks started by hand carry their starter's rules.
7. Managing it, and asking it questions
Lists, groups and bindings are managed over every transport: HTTP routes, gRPC RPCs, the MCP meta-tools groupdef, acldef, aclbinding and authz, a loomcycle acl CLI, and both adapters. They can also be declared in config and validated at load by the engine's own parser.
Management belongs to tenant operators and admin, and it asks its own questions: authoring a list needs author def:acl/<name>, so a deny on authoring holds for a tenant operator too. A delegated writer may not bind, join or edit whatever shapes its own authority (403 acl_self_elevation). None of it is reachable from inside a run.
Any principal may ask questions about itself. The authz endpoints answer four kinds: a single evaluation, a batch, an explain that walks every rule of the type and says why it did or did not match, and a simulate that evaluates a draft of lists and bindings over the stored state without writing anything.
In the Web UI, Settings → Access puts all of this on one page: groups with their nesting tree and effective members, rule lists in a structured editor that offers only what the catalog allows, bindings with expiry, and a simulator: "as alice, may I read document:tenant:/board/**?", with the rule trail behind the answer and a toggle to evaluate unsaved edits. Refusals anywhere in the UI read as sentences: "You may not read this document, denied by list board-readers v3, rule 2." The navigation shows the pages the caller's gates admit, instead of showing a page and then a 403.
8. When a person leaves
A subject erasure used to leave the person's group memberships and bindings in place, still naming them, and their member tokens kept authenticating. The erased user could sign in and write new data.
Now erasure removes the subject's memberships and every user binding in one transaction, revokes their member tokens, flushes the auth cache on every replica so a revoked bearer is refused at once, and records what it removed as a structured access field on the report and the result. Privileged tokens and config-declared memberships are left in place and named, so an operator knows exactly what still needs a hand.
Access control is rarely the feature anyone asks for first. It is the feature that decides whether a system can hold more than one kind of reader. Until it exists, every new group means a new deployment, or a new agent with a narrower prompt, or a policy that lives in someone's memory instead of in the system.
What we wanted was rules that sit beside the work instead of in front of it: written once, readable by the people they affect, previewed before they bite, and carried by every run wherever it goes, through a pause, a restart or a schedule firing at night. A deny that holds in a request but leaks through a resumed run is not a rule. It is a suggestion. The rule is the one that still holds after everything else has moved.
And yes, we're moving towards enterprise deployments. More enterprise-ready features will come soon.