# Permissions

> Status: built. The policy is `internal/domain`; the search filter is `internal/search/filter.go`;
> the route classes are `internal/api/server.go`. Why there are no administrators:
> [adr/0013](https://codna.me/docs/adr/0013-no-administrators.md).

CoDNA's central constraint: an AI agent acting for a person must see exactly what that person can
see. Not more. Every rule below exists to make that true on all three surfaces — the REST API, the
MCP server, and search — without restating the rules three times.

## Who sees what

Nobody sees everything. There are no roles and no administrators. A person sees:

- the documents they created,
- the documents shared with them, and
- the documents shared with a group they belong to — including **Everyone**, the built-in group
  every person is in.

A new document is visible to its creator and nobody else. Nothing is public by default. Widening
this later is easy; discovering that everything was readable is not.

Folders follow from the same rule, because they are not stored: a folder is what the paths of the
documents someone can read imply ([data-model.md](https://codna.me/docs/data-model.md#folders)). The tree
(`GET /api/documents/tree`) and the list of a space's folders (`GET /api/folders`) show a person
only the folders holding something they can read, counted by what they can read. A folder's name is
part of an address, and an address gives content away, so a folder holding only documents someone
cannot read is not there for them at all.

Running the instance — people, groups, tokens, status — is not done by a person at all. It belongs
to **management**, a principal that never reads a document ([below](#management)).

## The actor

Every operation takes an `Actor`. It is the only thing the policy consults.

```go
type Actor struct {
    ID       uuid.UUID
    Email    string
    Name     string
    Kind     Kind        // person | management | agent
    GroupIDs []uuid.UUID // every group the actor belongs to, Everyone included; none for an agent
    Via      AuthMethod  // session | oidc | automation | token | cli
    TokenID  uuid.UUID   // the service token management is acting through, if any
}
```

`Kind` decides the most: management is refused every decision the policy makes about a document.
`Via` never changes what a person may do — an agent authenticating with an OIDC token has precisely
the access of the person that token identifies. It is carried for audit, so "who did this, and
through what" is always answerable. For management it draws one line: the command line on the host
may do two things a token may not ([below](#the-command-line)).

Each surface takes one credential and resolves it to an actor:

- **Web UI.** A session cookie, from an email and password login, or from "Continue with SSO" (an
  ID token from the identity provider, for the web client). It resolves to a person:
  `sessions` → `users`. The SSO sign-in first resolves `identities (issuer, subject)` → `users`.
- **MCP.** An OAuth bearer token from the deployment's identity provider. It resolves to a person:
  `identities (issuer, subject)` → `users`.
- **REST API, for tooling.** A service token, `codna_…`. It resolves to management, through that
  token.
- **The host.** The `codna` command, with the server's environment. It resolves to management, via
  `cli`.

**Identities are keyed on `(issuer, subject)`, never on email.** IdP addresses are mutable and get
reassigned: give a departed employee's address to a new hire, and email-matching would hand the
newcomer every document the previous holder owned. The subject claim is opaque and stable — Dex
issues things like `CiQ2ZjNhMWMyZS0…` — which is precisely what an identity key should be.
See [adr/0012](https://codna.me/docs/adr/0012-mcp-oauth-resource-server.md).

Automations construct an `Actor` for their **owner**, never for the user whose action triggered
them. A script cannot be used to borrow someone else's visibility.

## The policy

These are pure functions in `internal/domain` with no database access, so they can be tested
exhaustively against a truth table rather than inferred from integration tests.

```go
func CanRead(a Actor, d DocumentFacts) bool
func CanWrite(a Actor, d DocumentFacts) bool
func CanMove(a Actor, d DocumentFacts) bool
func CanShare(a Actor, d DocumentFacts) bool
func ExplainRevoke(a Actor, d DocumentFacts, grantedBy *uuid.UUID) Decision
func CanDelete(a Actor, d DocumentFacts) bool
func CanRestore(a Actor, d DocumentFacts) bool
func CanCreate(a Actor, s Space) bool
```

Each has an `Explain…` twin that returns the reason with the decision, so a denial can be logged and
"why can this person see this?" answered. `DocumentFacts` is `{ID, Space, Status, CreatedBy,
Deleted, Quarantined, Grants}` — storage-agnostic on purpose, so the same function serves a row
from Postgres and a hit from Typesense.

### Truth table

| Actor relation | read | write | share | delete | restore |
|---|:--:|:--:|:--:|:--:|:--:|
| creator | ✅ | ✅ | ✅ | ✅ | ✅ |
| granted write (directly or via a group, Everyone included) | ✅ | ✅ | ✅ | ❌ | ✅ |
| granted read (directly or via a group, Everyone included) | ✅ | ❌ | ❌ | ❌ | ✅ |
| no relation | ❌ | ❌ | ❌ | ❌ | ❌ |
| management, whatever the document says | ❌ | ❌ | ❌ | ❌ | ❌ |

A tombstoned document is unreadable and unchangeable by everyone, including its
creator. Only `restore` remains available, and only to people who could read it
before it was deleted.

A quarantined document ([below](#break-glass-without-reading)) has no grants left, so only its
creator reads it, and the share column is ❌ for everyone, its creator included.

Rules that cut across the table:

- **Management is refused first.** Every policy function says no to management before it looks at
  the document, so a grant to management's id, or a document naming it as creator — neither of
  which anything in CoDNA can produce — would change nothing. Management cannot create a document
  either.
- **`raw` is never writable.** Not by its creator, not by anyone. Raw documents are evidence: if
  they can be edited, nothing in `wiki` can be trusted to trace back to what was actually ingested.
  Correcting raw material means superseding it with a new ingestion, which leaves both versions and
  an audit trail. Settled — see [adr/0004](https://codna.me/docs/adr/0004-three-spaces.md).
- **Whoever can edit a page can share it** ([adr/0020](https://codna.me/docs/adr/0020-editors-can-share.md)): its
  creator, and anyone holding a write grant, their own or a group's. A reader cannot. An editor
  shares as the creator does, read or write, with anyone. Provenance is kept by the grants
  themselves, since each records who made it (`granted_by`):
  - **An editor takes back only the shares they made**; the creator takes back any
    (`ExplainRevoke`). The access list marks each grant the caller may remove (`can_revoke`).
  - **A raise does not take a grant over.** An editor turning someone else's reader into an editor
    leaves the grant with whoever made it, so an editor can never take away a share the creator
    made. The creator raising a grant makes it theirs. Every raise is in `access_events`.
  - **The creator is told** (`access.reshared`) whenever somebody else widens their page's
    audience, a share with Everyone included.

  To keep a page's audience in your own hands, give read, not write. Management shares nothing,
  but it can widen an audience in three other ways, each in its audit and each announced to somebody: who is in a group (the person is told,
  [below](#groups-and-everyone)), who owns a departed person's documents (the new owner is told,
  [below](#when-someone-leaves)), and giving back the shares a quarantine moved aside (the owner is
  told). A document shared with Everyone also reaches each new person as they join.
- **A quarantined document is shared by nobody.** `ExplainShare` refuses it before it asks who is
  sharing, so its creator is refused too, and so is every path that grants — a direct share, a share
  by tag, an accepted suggestion, an automation, an agent over MCP — because they all ask here. A
  share checks again under the document's row lock, which a quarantine takes first, so a grant
  cannot slip in behind one.
- **Moving is not editing.** `raw` content is immutable, but a document's
  *address* is not part of its content. Ingestion files raw material under
  `YYYY/MM`, and forbidding anyone from ever reorganising that would be
  immutability enforced past the point it means anything. `ExplainMove` is
  therefore separate from `ExplainWrite` and ignores space immutability.
- **Restore belongs to everyone who had access.** Accidental deletion in a knowledge base costs more
  than accidental restoration, and every restore is attributed. Grants are what make "had access
  before deletion" answerable afterwards: they survive a tombstone, and no new grant can be added to
  a deleted document because sharing is denied once deleted. So, short of management taking shares
  away or a release giving a quarantine's back, the grants on a tombstone are exactly the grants
  that existed when it was deleted.
- **Linking to a document confers nothing.** A link is text in somebody else's document. It is not a
  relationship to the target, and it never grants read, write, share, delete, or restore.
- **Who a document is shared with is for whoever may share it.** They may list its grants
  (`GET /api/documents/{id}/access`), and the document itself tells them how widely it is shared —
  `sharing` on a page and on every list and tree row: how many people and groups, Everyone counting
  as one, and whether Everyone is among them. That is its creator and its editors, who need both
  to share sensibly. Readers get neither, so a document does not tell a reader who else reads it.

## Groups and Everyone

Groups exist so access can be granted to a team rather than to a list of names. Changing them —
creating, renaming and deleting groups, adding and removing members — is management's. A person who
could do it could make a group called "leadership", put themselves in it, and read whatever had
been shared with it; and every past grant to a group changes meaning when its membership does.

People can see which groups exist and how large they are (`GET /api/groups`), who is in any group
but Everyone (`GET /api/groups/{id}/members`), and which groups they are in themselves
(`GET /api/me/groups`) — the answer to "why can I see this?".

Someone sharing a document can ask who they can name (`GET /api/me/people`): everyone in a named
group, everyone they have shared with, and everyone who has shared with them, by name and email —
active people only, never management, never themselves, at most 500. It reveals nothing a
person could not already list: a named group's roster is open to them, the people they shared
with are addresses they typed, and the people who shared with them were named in the notification
that told them. Everyone's members are not on it, for the reason they cannot be listed, and the
whole roster stays management's (`GET /api/users`).

Membership changes cost nothing downstream and take effect on the person's next request, because
nothing has memorised who is in what ([ACL keys](#acl-keys)). Joining or leaving a group puts a
notification in the person's inbox (`group.added`, `group.removed`): a group is a set of documents
someone can now read, or no longer can. Deleting a group deletes every grant made to it, each
recorded as a revocation.

**Everyone** is a built-in group, slug `everyone`, with the fixed id
`00000000-0000-0000-0000-000000000002` and no member rows. Every person's group ids include it,
added where their groups are read, so someone who joins the instance later is in it from their first
request and nothing has to keep it up to date. Sharing with Everyone is how a document reaches the
whole organisation; `everyone`, in any case, is accepted wherever a share recipient is. It is not an
exception in the policy or the index — it is an ordinary `group:` key that every person presents.
It differs from other groups in three ways:

- It cannot be renamed or deleted. A database trigger refuses the delete as well, so a binary that
  does not know Everyone is special cannot remove it either.
- Its members cannot be added, removed or listed. Listing them would make the knowledge base a staff
  directory; the group's size — the number of active people — is on the group itself.
- Sharing with it notifies nobody. Otherwise one share would land in every inbox in the
  organisation.

## ACL keys

Grants are written to the search index as strings:

```
user:018f3c2a-…
group:018f4b71-…
```

A person expands to their own key plus one per group they belong to, Everyone's
(`group:00000000-0000-0000-0000-000000000002`) among them. Management has no keys at all. That
expansion happens at **query** time, not index time — which is the point:

> If group membership were flattened into per-user keys at index time, adding one person to one
> group would require reindexing every document that group can see. Keeping `group:` keys opaque
> makes membership changes free.

## Keeping search honest

`document_access` and Typesense must agree, or search leaks. They agree because the index carries
the same facts, and because **filtering happens inside Typesense, not after it**:

```
filter_by: acl_read:=[user:<me>,group:<a>,group:<b>] && status:=active
```

Post-filtering results after they return would corrupt `found` counts, pagination, and facet
counts — a person would see that *something* exists behind the gap even if they could not open it.
Filtering in the engine means a hidden document is not merely unopenable; it is unobservable.

Every query carries exactly one ACL clause. There is no viewer without one.

The filter is only as current as the index, so each page of hits is then confirmed against
Postgres by the same policy as opening a document, and a hit the person can no longer read is
withheld. A revoked share therefore stops showing in search at once, even while the index has yet
to catch up ([search.md](https://codna.me/docs/search.md#acl-filtering)).

One edge case worth stating: an actor with **no** principal keys must match nothing. Management is
exactly that actor — no keys by design, and kept away from search by its routes as well — but the
filter does not rely on the routes. An empty key set renders a clause no key can satisfy, rather
than an empty list, which Typesense would ignore and answer with the whole knowledge base. Until
[adr/0013](https://codna.me/docs/adr/0013-no-administrators.md) there was also an admin, whose queries had no ACL clause
at all, and conflating that "unrestricted" with "restricted to an empty set" was the obvious way to
build a total-disclosure bug. That branch is gone; nothing is unrestricted now.

Exact filter construction in [search.md](https://codna.me/docs/search.md).

## Denied reads report `not_found`

A read the actor is not allowed to perform returns `not_found`, never `forbidden`.

`forbidden` confirms the document exists. Combined with an addressable URL scheme, that is enough to
probe for the existence of `wiki:legal/acquisition-2027` — and the address usually gives away the
content. `not_found` is indistinguishable from the document never having existed.

`forbidden` is still used where the actor already provably knows the document exists: a `read`-only
grantee attempting a write gets `forbidden`, because they can already see it.

A management token gets `forbidden` from every content route, but before any document is looked up:
the refusal is about the credential, so it says nothing about what exists.

## Share suggestions

When a document is shared, CoDNA walks the link graph outward and proposes what else the recipient
may need. The traversal is in [data-model.md](https://codna.me/docs/data-model.md#share-suggestions-the-traversal); the
policy around it:

1. Traverse resolved and `stale_text` edges, **depth 1 by default** — the documents this one
   actually links to. Configurable, cycle-safe.
2. Keep only documents **the sharer may share** — so suggestions never reveal a document the sharer
   could not otherwise name.
3. Drop documents the recipient can already see — no noise. "Can see" is what reaches them, not
   only what was given to them by name: a grant to **Everyone** counts for every recipient, a group
   included, since every member of a group is a person and so in Everyone; for a person, a grant to
   **any group they are in** counts too. A group recipient is otherwise judged by its own grants
   and declines, not its members': what one member can open through another group is not what a
   grant to this group adds.
4. Drop pairs in `share_suggestion_decisions` — a declined suggestion stays declined.
5. Return each candidate with its depth **and the path that reached it**.

Point 5 is a permission concern, not a UX nicety. A list of titles with no explanation gets approved
in bulk, and bulk approval of an unexplained list is how over-sharing happens. Forcing the agent to
say *why* gives the user something to disagree with.

**Depth 1 was not the original design, and the reason for the change is worth keeping.** The spec
said depth 3, which sounded reasonable and degenerated completely on the first real corpus: a
densely linked wiki has a hub page, a hub puts every document within two hops of every other, and
every document then suggested all nine others. Measured, not guessed — the suggestion list was
literally "everything" in all ten cases. A suggestion that is always the whole knowledge base
carries no information and gets approved wholesale, which is precisely the over-sharing this
feature exists to prevent.

Depth 1 is also the only setting whose suggestions can be justified in one sentence: *this document
links to that one*. A hub page still suggests everything, and that is correct rather than broken —
it genuinely does depend on all of them.

Nothing is ever shared automatically. Suggestions are suggestions.

**Sharing raises access, never lowers it.** A share is skipped only for a recipient who already
holds at least what is asked for: the document's creator, an editor, or a reader asked to read.
A reader shared with as an editor is upgraded; an editor shared with as a reader keeps editing,
whether the share was by hand, by tag or by an automation. Taking access away is a revoke.

The share preview says what the recipient may already do (`already_has_access`,
`current_permission`), counted the way suggestions are: through Everyone and, for a person, their
groups. When that is through a group rather than a grant of their own, `through` names the group,
so "they can already read it" comes with how. A client can then tell "can already" from "would
gain" before anything is granted; the web share dialog stops there and says so.

What a share skips by is narrower: **the recipient's own grant**. Someone who can read a page
because Everyone can, and is shared it by name, gets a grant of their own, and keeps the page when
Everyone's grant is taken away or they leave the group. The share was a statement about them, and
should not hang on a grant that was not. Folder shares and automations go through the same share,
so a folder shared with a team keeps reaching it whatever happens to a wider grant.

## Folder shares

A person can share a folder of theirs, standing: every document of theirs in it and beneath it is
shared now, and each one they put there later — written there, moved there, or restored there — is
shared as it arrives ([adr/0014](https://codna.me/docs/adr/0014-standing-folder-shares.md)).

- **Their own documents only.** A folder share is a standing rule about its owner's pages; one
  that reached every page its owner could edit would grant on other people's pages as they arrive.
  A page someone else keeps in the same folder is not reached, though an editor of it can share it
  by hand.
- **Each document gets an ordinary grant**, with reason `folder`, made through the same share every
  other share goes through: the policy, the events, the reindex and the notification are the same,
  and so is revoking it.
- **It is listed.** `list_folder_shares` and `GET /api/folder-shares` say which folders are shared
  with whom; `shared_with` reports the folder shares reaching a person beside what they were given.
  In the web app, **Share this folder** on a folder's page (or **Share this space** on a space's)
  makes one, lists the folder's, and ends one.
- **Ending one** stops what comes later. What it shared stays shared unless asked to take it back,
  and then only the grants folder shares made: a page shared directly stays shared.
- A page moved out of a shared folder keeps its grant: access does not change because someone
  tidied a folder.
- A departed owner's folder shares rest, like their automations.

## Links must not reveal what they point at

Writing `[x](../secret-acquisition.md)` creates a real graph edge whether or not the author can read
the target — the graph is a property of content, not of the viewer. What must never happen is that
edge telling its author something about the target.

Three queries would otherwise leak: stale and broken link decisions, backlinks, and link
suggestions. All three are ACL-filtered on the document being described. Without that, anyone could
guess an address and learn from the resulting notifications that the document exists, what it is
called, and when it moved or was deleted — the same disclosure that reporting denied reads as
`not_found` exists to prevent.

To an author who cannot read the target, such a link simply looks like one that never resolved.

One subtlety worth stating, because the obvious implementation gets it backwards: the filter for
link decisions **ignores the target's tombstone**. A deleted document is unreadable by everyone, so
a plain "can the actor read the target" check would suppress exactly the broken-link decision the
query exists to raise. Ignoring `deleted_at` makes the predicate mean *"does the actor have a
relationship to the target"* — which is read access for a live document and access-before-deletion
for a tombstone, the same rule that governs restore.

## Agents

An agent ([adr/0021](https://codna.me/docs/adr/0021-agents-are-members.md), [agents.md](https://codna.me/docs/agents.md)) is a third kind of
actor: a member that connects over MCP as itself, with a service account at the identity provider.

- **It is in no group**, Everyone included, so it reaches only what was shared with it by name.
  Management cannot add one to a group.
- **It reads, and writes only its own pages, in `output`.** It creates nowhere else, and moving is
  not a way out. A write grant reaching it changes nothing: it edits only what it wrote.
- **It shares, deletes and restores nothing**, its own pages included. `ExplainShare`,
  `ExplainDelete` and `ExplainRestore` refuse an agent before they look at the document.
- **Only its responsible people share with it, and only to read.** Management names them; each is
  told when they become one or stop being one. A share with an agent by anyone else is refused, and
  so is a folder share, so a standing one whose owner stops answering for the agent rests.
- **It is refused everywhere but `/mcp`.** The REST API answers an agent's token with a 403, and
  the web app has no sign-in for one.
- **It is not a person anywhere people are counted**: not in the share field's people, not in
  usage's active people, not in the roster.
- **Its responsible people stand in for its creator.** `Actor.StewardOf` lists the agents a person
  answers for, loaded with their groups on every request, and `IsCreator` counts them as the
  creator of those agents' pages: they read, edit, share and delete them. A rule, not grants, so
  taking someone off an agent ends it at once. Those pages stay out of their own listings and
  search, which follow grants and the index; the agent's page lists them.
- **They see what it sees, read only.** Any of them may take a page out of what the agent knows,
  whoever shared it. With `X-Codna-View-As: <slug>`, the routes that read pages (listings, the
  tree, folders, a page, its history and files, search) answer as the agent, with every access but
  read switched off; every other route refuses a change carrying the header. Each use is counted by
  person and hour in `agent_views`, which the agent's other responsible people see.

## Automations

Scripts run as their **owner**, and the sandbox enforces that in both directions:

- **Reads** (`codna.find`, `codna.get`) execute through the same ACL-filtered path as any search by
  that user, capped in result count.
- **Writes** are *proposed*, not performed. The script returns an action list; the host validates
  each action against the owner's permissions, then applies the survivors in one transaction.

So a script is bounded by its author. Rejected actions are recorded in `automation_runs` with a
reason rather than discarded, which is what makes a misbehaving script visible instead of merely
ineffective.

Resulting grants carry `grant_reason='script'`, so "why can this person see this?" is still
answerable after an automation has been running for months.

An automation, its script and its runs are visible to its owner alone — not to management, not to
anyone else. A script says what its owner works on.

A deactivated owner's automations neither run nor fail while they are away: events are passed over,
schedules stay due, and both resume when the owner is reactivated. If management hands them to
someone else instead ([below](#when-someone-leaves)), they arrive switched off: a script acts as its
owner, and the person it will act as should read it before it does. They arrive without their run
history too: those logs and actions were written while the script acted as the person who left, and
can name or quote documents only that person could read.

## Management

One built-in principal runs the instance: a `users` row with `kind = 'management'`, the fixed id
`00000000-0000-0000-0000-000000000001`, an empty email and no password. It never signs in, is never
a share target, never joins a group, and never owns a document or an automation. Every service token
acts as it, and so does the `codna` command on the host.

| Management may | Management may not |
|---|---|
| Add people, deactivate and reactivate them, and sign them out everywhere | Read a document, search its text, or learn its title, path, tags or content |
| Set a password, within [the rules below](#passwords) | Create, edit, move, share, delete or restore a document |
| Create, rename and delete groups, and change who is in them | Ingest, run or read automations, or read anyone's inbox |
| Hand a deactivated person's documents and automations to someone who stays | Export anything through the API |
| Find documents by what it may know of them, take a share away, quarantine and release | Join a group, be shared with, or own anything |
| Issue, list and revoke service tokens | Issue a token that can do more than it can |
| Read the instance status and the dead-lettered events, and retry them | |
| Read [how the instance is used](#how-the-instance-is-used): who is active, how much they write, which tools their agents call — in counts | Learn what anyone searched for, read or wrote |
| Read its own audit | |

People are added by management, or, with `CODNA_OIDC_AUTO_PROVISION` on, on their first sign-in
through the identity provider. `CODNA_OIDC_AUTO_PROVISION_DOMAINS` narrows that to addresses at the
organisation's own domains, so a provider that also signs in contractors or personal accounts
creates only colleagues, and everyone else still needs adding. Deactivating someone ends every
session they have; they cannot sign in again, by password or through the identity provider, until
they are reactivated. Someone the provider stops letting in, though, keeps a web session made
before then until it runs out: `CODNA_SESSION_TTL` says how long that is (two weeks unless set),
counted from sign-in, so shortening it shortens sessions already made too. Deactivation
rather than deletion: a departed colleague authored documents, made grants and owns automations,
and deleting the row would leave all of that attributed to nobody. What they owned stays theirs
until management hands it on.

### When someone leaves

A deactivated person's documents keep their grants, and their editors can still share them, but
nobody can delete them or take back a share they made, and their automations wait for someone who is
not coming back. So management hands them to someone who stays, without reading any of it:

- `GET /api/users/{id}` says what a person owns: live and trashed documents, automations switched
  on and off. Counts, never titles.
- `POST /api/users/{id}/transfer` with `{"to": "<email or id>"}` moves it — documents and
  automations, unless `"documents": false` or `"automations": false` leaves one behind. Only away
  from someone already deactivated, so taking a person's documents is never quiet: they were signed
  out first. Only to someone active, and never to the same person.
- **Documents move whole**, trashed ones included so the new owner can restore them, and nothing
  about them changes but the owner: not their version, not when they were last updated, not who they
  are shared with. They are the new owner's as if they had created them. `document_owner_changes`
  keeps who had each one before, and a `document.transferred` event per document rewrites the
  owner's key in the search index.
- **Automations arrive switched off**, for the new owner to read before they act as them, and without
  their run history, which recorded what they read as someone else.
- When anything moved, the new owner is told (`documents.transferred`). The transfer is in the
  management audit as `user.transfer`.

From the host: `codna people transfer <who> --to <who>`, or
`codna people deactivate <who> --transfer-to <who>` to do both at once.

### Break-glass without reading

Someone reports a document that should not be where it is: shared with the whole organisation,
holding a secret, owned by someone who has left. Management can act on it without opening it.

It finds documents by what it may know of them, never by what they say: the owner, the space, the
status, the dates, who a document is shared with, and whether it is held. No response carries a
title, a path, a slug, tags or content — an address is derived from the title, so it is content too
— and a test checks the responses key by key, against a document planted with a distinctive title,
path and body. A person reports a document by its id, which the web UI shows under a document's
Details for exactly this.

What management can do about one:

- **Take a share away** — one (`everyone`, `group:<slug>` or an email) or every one. Advisory: the
  owner is told (`document.access_revoked`), and the owner or an editor may share it again, by hand
  or through an automation. A quarantine is what holds.
- **Quarantine it**, with a reason. Every share is moved aside and kept, and nobody can share it
  until it is released — not even its owner ([the policy](#the-policy)). The owner still reads and
  edits it, sees a banner with the reason, and is told (`document.quarantined`). Nobody else sees
  it: a quarantine is the grants moved aside, not a new rule, so the visibility queries and the
  search filter show the document to its owner alone without knowing quarantines exist.
- **Release it**, giving the shares back as they were — who made them, why and when — or leaving it
  unshared. A share to someone since deactivated, or to a group since deleted, is not given back,
  and the release says which. The owner is told (`document.released`). Shares given back include
  the history: a secret the owner edited out during the quarantine is still in an earlier
  revision, which everyone who can read the document can read. Revisions are kept forever.

Each action is in the management audit with the document's id (`document.unshare`,
`document.quarantine`, `document.release`), and a document's quarantines and owner changes come
back with it from `GET /api/manage/documents/{id}`. From the host: `codna documents find`,
`unshare`, `quarantine` and `release`.

The cost is what management sees: every document's owner, dates and sharing — a sharing graph a
person sees only for the documents they can read. That is the least it needs to act on a report,
and it is still not what any document says.

### How the instance is used

Whoever runs an instance asks whether anyone uses it, and how. `GET /api/manage/usage?weeks=N`
(8 unless asked, at most 52) answers in counts, for each calendar week — Monday to Sunday, UTC,
the current week so far included — for the last seven days, and for each person over those weeks:

- **Who was active**: people who wrote, signed in or used the web app, or had an agent call a
  tool. Each counts once a week however many ways they used it, and the week also says how many
  did each.
- **Writing, by space**: pages created, pages edited (each once however often it was saved), the
  saves, and the pages written at all, created or edited, each once. An edit is a revision that changed what a page says, its title, its tags or its
  status; a move, a delete or a restore is not one. A page counts in the space it is in now, and
  what an automation writes counts as its owner's, since it acts as them.
- **Sign-ins**, through a password or the identity provider.
- **Tool calls by tool and outcome**: answered, refused (the tool's own answer — not found, not
  allowed, changed since it was read) or failed. A tool the server does not have is counted as
  `(unknown)`.
- **For each person**: sign-ins, days on the web, pages created and edited, tool calls by tool, and
  when they were last active, however long ago — the last time anything here saw them, or the
  identity provider vouched for them, which every agent request does.

What it never says is what anyone searched for, read or wrote. No response names a document, a
title, an address, a query or a tool's arguments — not even which document a call touched. People
are named, as in `GET /api/users`. Reading a page in the web app is counted only as being on the
web that hour: the app is counted at most once an hour per person, while it is open and in view.

Writing is counted from the revisions, so it reaches as far back as the history does. Sign-ins,
web use and tool calls are counted in `usage_daily`, one row per person, day, kind and tool, from
the release that added it; `counted_since` in the response says when that was, so an empty week
before it reads as "not counted" rather than "nobody". The sign-ins the sessions table still held
then were counted in: a session lasts two weeks and goes when its person signs out, so that reaches
no further back. From the host: `codna usage [--weeks N]`.

### Route classes

Every API route has exactly one class, and the class — not the handler — turns the wrong caller away
before any handler runs:

- **`public`**, for anyone: signing in and out.
- **`authed`**, for a person or management: `GET /api/auth/me`, `GET /api/instance`, listing groups
  and a group's members.
- **`content`**, for people only: documents and their folders, search, sharing, ingestion,
  automations, the inbox, link decisions and suggestions, a person's own groups and the people they
  can name, export, changing one's own password.
- **`manage`**, for management only: `/api/users…`, `/api/tokens…`, changing groups,
  `/api/admin/status`, `/api/admin/events/…`, `/api/manage/audit`, `/api/manage/documents…`,
  `/api/manage/usage`.

A management token on a content route gets 403 `forbidden` with a message naming the management
token, which a console can check to prove that the token it holds cannot read. A person on a
management route gets 403 `forbidden` with the reason `management_only` — the first person
included. A test walks every route in each class rather than the routes someone remembered.

`GET /api/auth/me` tells a caller what it holds: `kind` is `person` or `management`, and for
management `via` is `token` and `token_id` names the token. There is no role. For a person it
also says `has_password`, and `agent_last_call_at`: when an agent last called a tool for them, or
null.

### Service tokens

A service token is management's credential for the REST API: for a console or other tooling that
runs the instance, a provisioner, a monitor. Every token acts as management, never as a person, and
has one scope, `manage`. The first comes from the host — `codna tokens create <name>`, or
`codna bootstrap --service-token` on a fresh instance — and a token can issue others with
`POST /api/tokens`. Tokens are stored as SHA-256, shown once at creation, revocable, and optionally
expiring.

A token cannot widen itself. There is no scope to ask for but `manage`; there is no admin to create
or promote — `POST /api/users` takes no role but `member`, and that only for one release, and
`POST /api/users/{id}/role` answers 410 Gone; and the only way to a person's sign-in goes through
[the password rules](#passwords).

Tokens are accepted by the REST API only. `/mcp` refuses them by shape — an agent holding a
long-lived credential is exactly what [adr/0012](https://codna.me/docs/adr/0012-mcp-oauth-resource-server.md) keeps
out of MCP — so no person ever authenticates with one, and offboarding stays in the identity
provider.

Tokens from before [adr/0013](https://codna.me/docs/adr/0013-no-administrators.md) with the `admin` or `read` scope were
revoked by migration 00002; `manage` tokens kept working and moved to management.

### The command line

`codna people`, `codna groups`, `codna tokens`, `codna documents`, `codna status`, `codna usage`
and `codna events` run the instance from the host, as management via `cli`, through the same services
and rules a token goes through. Each change is audited. Two things are allowed there and nowhere
else, because whoever can run them already holds the database:

- setting any password — on an instance with single sign-on, and for an account that had none;
- `codna export --all`, every document whoever can read it ([export.md](https://codna.me/docs/export.md)), which is in
  the management audit as `export.all`.

`codna bootstrap` makes the first person — a person like any other, who reads what they own and
what is shared with them, and runs nothing — and, with `--service-token`, a management token.
`codna ingest`, `codna import` and `codna export --as` act as a named, active person, never as
management.

### Passwords

The one place management touches how someone signs in, so the narrowest:

- **A manage token never sets a password on an instance with web single sign-on**
  (`CODNA_OIDC_WEB_CLIENT_ID` set). Adding a person with a password is refused — leave it empty and
  they sign in through the identity provider — and so is a reset. A password management chose is a
  way to sign in as somebody, and on such an instance people's sign-ins belong to the identity
  provider.
- **A manage token never gives a password to an account that has none.** An account made for single
  sign-on stays that way.
- **A reset signs the person out everywhere and tells them**, with an `account.password_reset`
  notification in their inbox, so a reset they did not ask for does not go unnoticed.
- **The command line may set one regardless** — that is how anyone recovers — and its resets sign
  out and notify the same way.

A person changes their own password with `POST /api/auth/password`, which asks for the current one
again: a session left open on a shared machine should not be enough to lock its owner out.

## Audit

These tables answer access questions, none of which can be reconstructed from the others:

- **Who can see this document *now*?** `document_access` and `documents.created_by`, with group
  membership.
- **Why can they see it?** `document_access.grant_reason` and `batch_id`.
- **What changed, and when?** `access_events`, append-only.
- **What did a script do?** `automation_runs.actions`.
- **Who owned it before?** `document_owner_changes`.
- **Why was it taken out of circulation, and what did that take away?** `document_quarantines`.
- **What did management do, through which token?** `management_audit`, append-only.

The management audit gets a row for every change management makes, written in the same transaction
as the change — among them `user.create`, `user.activate`, `user.deactivate`, `user.password_set`,
`user.sign_out`, `user.transfer`, `group.create`, `group.update`, `group.delete`,
`group.member_add`, `group.member_remove`, `token.create`, `token.revoke`, `event.retry`,
`document.unshare`, `document.quarantine` and `document.release` — and one for every
`codna export --all` (`export.all`). Each records `via` (`token` or `cli`), the token, the ids it
touched, a few details such as the email a person was created with or a quarantine's reason, and,
for an API call, the request id. Never content. Management reads it with `GET /api/manage/audit`,
newest first, filtered by `user`, `document` or `action` and paged with `before`.

These are the paths by which management could still reach content — adding a person, setting a
password, changing who is in a group, handing someone's documents to someone else — which is why
each one leaves a row, and why a password reset, a change of groups and a transfer also tell the
person concerned.

## Deferred

- **Tag-scoped standing grants.** Deliberately out: a tag is applied by anyone who can edit, so a
  rule on a tag would widen a page's audience every time the tag lands on it, with nobody choosing
  that page. An editor can share a page directly, and visibly. Continuous behaviour by tag comes
  from automations, where every grant is attributable to a named script, a run record, and an
  owner. Folders are the exception, since 2026-10-14 — see [Folder shares](#folder-shares) and
  [adr/0014](https://codna.me/docs/adr/0014-standing-folder-shares.md).
- **Public links.** No anonymous access anywhere in the model.
