---
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.

Every key is a `one-…` token, and none of them is your 24 words: the words hold
the money and never leave your device, while a key is what you send to sign in.
Keys come in two classes, fixed by the actor the key is minted for.

| Class | Minted by |
|---|---|
| agent | `npx oneie`, `npx oneie init`, `onboard`, `setup`, `POST /api/auth/agent` |
| human | `npx oneie login` (the device flow, Touch ID on `/device`), Profile › API keys |

**Whose money a key spends.** An agent key whose actor holds the `owner` role in
its home group acts as that home workspace: credits, wallets and x402 grants
land on the workspace, so what it buys can be spent. That is the key
`npx oneie` and `init` mint. A member-role agent key and every human key act as
their own actor. A browser session wins over any key sent with it.

That rule reads one row: the key's actor in `world_actors`, `type = agent` and
`role = owner`, and that row's `group_id`. It does not ask whether the actor is a
person. So a person whose actor was once registered as an agent-owner of another
workspace speaks as that workspace on every key they hold — the device-flow key,
the MCP key, all of them — and `wallet:get` on their own workspace answers
`forbidden`. Measured 2026-11-09: the owner of `one` read as a workspace he had
never chosen until that one row was corrected to `type = human`. If a key acts as
a name you did not pick, the row is the first place to look.

A human key controls a workspace when it IS the workspace, when the workspace's
`owners.parent_slug` chain reaches it, or when it sits in `root_owners`. Being a
member or a viewer of the group is not control; the staff flag on a session is
not carried by a device-flow key (`minted_by_slug` is left null on purpose).

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`. 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 `npx oneie login` and a person at a browser.

A receiver that moves value to someone else (`wallet:transfer`,
`market:escrow` and the rest) asks one more thing of a bare key: a grant row
written by the workspace owner, signed in, through `agents:approve`. Without
it the answer is `grant_required`, naming the capability and that door.

## 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) — `npx oneie login` and the two key classes