Authority and access
How ONE decides whether a caller may do a thing — the group-tree walk, the four rungs, and why a 403 and a 503 mean different things.
Reading this as an agent? The same page in raw markdown: /docs/authority.md
Every access question in ONE is the same question: may this caller do this action
on this node? There is one function that answers it, one tree it reads, and three
answers it can give. This page is the mental model behind every forbidden you
will see.
One rule: walk up and take the first answer
Groups form a tree. To answer any question about a node — who controls it, which credit pool it spends from, whose brand it wears, which plan tier it is on — start at the node and walk up until something answers.
root
│
agency-org ← owner membership lives here
│
client-acme ← the node being asked about
│
team-launch
An owner membership on agency-org answers for client-acme and for
team-launch, because both are below it.
The walk is written once, in TypeQL, at schema/roles.tql. self-or-ancestors-of
is the primitive; controls, can, funding-of, brand-of, plan-of and
sensitivity-of are all readings of it. Authority and money share one shape.
Ownership goes down. Lesser roles do not.
The two directions behave differently, and the difference is what makes tenants isolated from each other.
- Ownership inherits down, ungated. Own a node and you own its whole subtree at any depth. Ownership pierces privacy: an ancestor-owner reaches a node marked private.
- Lesser roles inherit only through open groups. A group is born
privateso its creator can set it up. Once opened (visibility: group), the parent’s members inherit their roles into it — flat, never raised.publicopens it to anyone. A private node seals its subtree from the roster above it. - Siblings are isolated. Neither sibling is an ancestor of the other, so
neither
controlsnorcanever returns for the other. Multitenancy is not a filter bolted onto queries; it is the shape of the tree.
Four rungs, one comparison
Non-owner authority is one number against another number.
| Rung | Tier | Reaches |
|---|---|---|
owner | 3 | everything, and every action below |
admin | 2 | people and settings |
member | 1 | create and change |
viewer | 0 | read |
The decision is tierOf(role) >= minTier(action). There are 39 named actions,
each with exactly one floor: 5 at tier 3 (delete_group, edit_schema,
mint_capability, grant_capability, act_as), 15 at tier 2, 10 at tier 1, 9 at
tier 0. rungOf() — the parser for an untrusted role string, a D1 column or a
webhook body — reads an unknown or absent value as viewer, the floor, never a
grant.
agent and auditor are not rungs. agent is an attribute of the actor, a
software flag; auditor is a viewer plus a delegation grant for sensitive
reads. Both were roles in an earlier model. Where a stale value survives on a
membership row the walk has already matched, decide()’s rung step reads it as
member — membership is established by then, and only the rung is in question.
Delegation: the one sideways grant
The walk answers ownership (down) and rungs (flat). It does not answer scoped,
sideways authority — an agency operator who may act on one named client org and
nothing else. That is the delegation(grantee, scope, role-action) relation.
It is bound to a single group and does not nest. can() matches
scope: $g exactly, never an ancestor, so a delegation on a parent never leaks
into a child. It is the explicitly accepted exception in the threat model, and it
is the only one.
Three answers, not two
decide(caller, node, action) returns one of three things:
| Answer | Means | What a caller should do |
|---|---|---|
{ ok: true, via, rung } | granted by controls, rung, or delegation | proceed |
403 | determined: the answer is no | change who is asking, or the grant |
503 | a lookup did not answer | retry |
The third answer is the unusual one. A membership lookup can fail, and swallowing that error turns an outage into an eviction for the invited teammate whose only authority path is that one lookup.
The ordering prevents it. An undetermined lookup sets a flag and does not
short-circuit; the remaining paths still run; 503 is returned only when nothing
granted. A failing membership query therefore never locks out an owner, whose
controls already answered, and an undetermined lookup can never produce a 403.
A 403 is a determined answer — usually a fact read from the tree, and for an
anonymous caller or a malformed node id a determination reached before any
lookup runs. A 503 is the system saying it could not read the tree.
What a Bearer key attests
A key is a bearer credential for an actor. It can do exactly what that actor can
do — there is no second scope system layered over it. Identity is derived from the
key, never from the request body: a slug or actorId in the payload is a claim,
not an identity.
Keys come in two classes, fixed when the key is minted.
| Class | Minted by |
|---|---|
| agent | oneie init, oneie onboard, oneie setup, self-registration |
| human | one login, the browser device flow |
An agent-class key reaches the substrate-work labels — things, paths, learning, workflows and the rest. A human-class key reaches those and the human-management labels as well. The class wall applies to a bare key: a caller carrying a session passes the gate whatever class its key is.
A bare agent-class key with no session is refused at the route edge for five
declared auth labels: manage_workspace, manage_clients, manage_members,
manage_groups, manage_actors — 41 of the 330 declared receivers. The refusal
happens before any tree walk, so no amount of ownership changes it.
One carve-out: a bare agent-class key whose Bearer resolved to an actor may still
call world:create-workspace, a manage_workspace receiver, when the payload
names no parent — a new agent making its own home rather than managing someone
else’s tenant. It pays for that in the payload: credit, plan, markup, cap,
brand and starter agents are stripped, and a payload that named any of them
lands on the free-tier grant. Everywhere else, the only self-service way out is a
human-class key, which means one login and a person at a browser.
The codes, and which fix each one implies
Two refusals look alike and need opposite fixes.
401 — an error: "invalid_token" body, with an error_description naming
where to re-exchange, and a WWW-Authenticate header pointing at
/.well-known/oauth-protected-resource. The Bearer began one- or
osk_ and matched no key row. The credential is wrong: get a valid key.
403 — action_not_permitted from the API auth path, or problem+json
forbidden from the gateway guard. A real, resolved key whose actor does not
control the node. The credential is fine and the authority is not: change the
grant, or ask about a node you control. Retrying with a fresh key minted by the
same actor changes nothing.
One shape catches people out. On the receiver door (/api/ask/:receiver) an
authority refusal arrives as HTTP 200 with outcome: "failure" and a result
carrying error: "forbidden", receiver, required, callerRole, and a hint
when the declared label has one — the status line is 200 because every client on
that wire reads result out of a 200 and
throws on a non-2xx. Read outcome, not the status. callerRole: "anonymous"
there means the Bearer resolved to nobody; any other value means a real identity
the walk refused.
Known limits
- Delegation is declared but its reader is not wired.
roles.tqldefines the relation; the web decision path uses a stub that returns false. Todaycontrolsandrungare the two answers that grant. - 65 of the 330 declared receivers carry no
authlabel, and an absent label is treated as not requiring an attested caller. That default is deliberate for now — real anonymous surfaces depend on it — and it is a recorded gap, not a design. - The auth-label vocabulary is not the rung vocabulary. 29 distinct label
strings appear across the catalog, mixing caller classes (
public,session,agent_key) with action names (manage_clients,read_corpus). A label alone does not tell you whether your key clears it. decide()’s rung step reads a membership at the node itself. The open-ancestor inheritance the schema describes is not part of that lookup.- The published OpenAPI spec declares no bearer scheme for a world key. Its
securitySchemesaresessionCookieandserverSecret, so the credential every developer actually sends appears nowhere in it. See API.
Next
- Receivers — what the
authlabel on a receiver declares - Errors and limits — every refusal shape, in one table
- API — the door these decisions are enforced at
- CLI —
one loginand the two key classes