---
title: "Authority and access"
description: "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."
type: explanation
audience: both
verified: 2026-09-06
canonical: https://one.ie/docs/authority
---
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.

```text
        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 `private` so
  its creator can set it up. Once opened (`visibility: group`), the parent's
  members inherit their roles into it — flat, never raised. `public` opens 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 `controls` nor `can` ever 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.tql` defines the
  relation; the web decision path uses a stub that returns false. Today `controls`
  and `rung` are the two answers that grant.
- **65 of the 330 declared receivers carry no `auth` label**, 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
  `securitySchemes` are `sessionCookie` and `serverSecret`, so the credential
  every developer actually sends appears nowhere in it. See [API](/docs/api).

## Next

- [Receivers](/docs/receivers) — what the `auth` label on a receiver declares
- [Errors and limits](/docs/errors) — every refusal shape, in one table
- [API](/docs/api) — the door these decisions are enforced at
- [CLI](/docs/cli) — `one login` and the two key classes