Docs How it works
Permissions
Status: built. The policy is
internal/domain; the search filter isinternal/search/filter.go; the route classes areinternal/api/server.go. Why there are no administrators: adr/0013.
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). 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).
The actor
Every operation takes an Actor. It is the only thing the policy consults.
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).
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 resolvesidentities (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
codnacommand, with the server's environment. It resolves to management, viacli.
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.
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.
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) 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.
-
rawis never writable. Not by its creator, not by anyone. Raw documents are evidence: if they can be edited, nothing inwikican 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. -
Whoever can edit a page can share it (adr/0020): 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), who owns a departed person's documents (the new owner is told, below), 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.
- An editor takes back only the shares they made; the creator takes back any
(
-
A quarantined document is shared by nobody.
ExplainSharerefuses 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.
rawcontent is immutable, but a document's address is not part of its content. Ingestion files raw material underYYYY/MM, and forbidding anyone from ever reorganising that would be immutability enforced past the point it means anything.ExplainMoveis therefore separate fromExplainWriteand 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 —sharingon 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). 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).
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 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.
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; the policy around it:
- Traverse resolved and
stale_textedges, depth 1 by default — the documents this one actually links to. Configurable, cycle-safe. - Keep only documents the sharer may share — so suggestions never reveal a document the sharer could not otherwise name.
- 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.
- Drop pairs in
share_suggestion_decisions— a declined suggestion stays declined. - 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).
- 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_sharesandGET /api/folder-sharessay which folders are shared with whom;shared_withreports 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, 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,ExplainDeleteandExplainRestorerefuse 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.StewardOflists the agents a person answers for, loaded with their groups on every request, andIsCreatorcounts 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 inagent_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), 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 | 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: 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}/transferwith{"to": "<email or id>"}moves it — documents and automations, unless"documents": falseor"automations": falseleaves 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_changeskeeps who had each one before, and adocument.transferredevent 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 asuser.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 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.
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 keeps
out of MCP — so no person ever authenticates with one, and offboarding stays in the identity
provider.
Tokens from before adr/0013 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), which is in the management audit asexport.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_IDset). 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_resetnotification 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_accessanddocuments.created_by, with group membership. - Why can they see it?
document_access.grant_reasonandbatch_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 and adr/0014.
- Public links. No anonymous access anywhere in the model.