The model in one page
Governance lives in the Station, the host that serves forests over HTTP and MCP. The engine underneath has no accounts: a Vine object holds whatever filesystem authority its process holds, which is the right answer for a library and the wrong one for a shared deployment. The Station is what adds identity, and it adds it at exactly one seam.
- Deny by default. No grant means no access, and no way to learn the forest exists.
- One enforcement seam. Every surface goes through the same scoped object, so a new route cannot forget the rules. There is no privileged side channel: the Studio console calls the same
/v1routes any client could. - The registry is not inside any forest. Principals, key digests, grants, bindings and the audit log live in a single SQLite file, deliberately outside the forest volumes, so a forest handed to another operator carries no credentials.
- Nothing is minted by starting the server. The registry holds exactly the same authority after boot as before it.
Principals
A principal is one identity: a person or a service. It carries an id, a kind (user or service), a creation timestamp, optionally a password, optionally the owner bit, and optionally an email, which is stored locally and never transmitted, so setup completes on an air-gapped host.
A principal has two possible doors. A password, which is guessable and therefore stored as a salted scrypt hash, and API keys, which are 256-bit random tokens and therefore stored as a plain digest. A password sign-in mints a session token that is an ordinary API key with a 12-hour life, so past the door there is exactly one authorization path.
The owner
Exactly one principal can hold the owner bit, and the database enforces that with a unique partial index rather than trusting whichever code path happened to create it. The owner holds admin on every forest present and future, including on none, which is the only shape that can create the first forest on an empty registry.
Why the owner is a bit and not a grant
A grant is revoked one forest at a time, which would leave a half-owner behind, and a grant cannot express authority over a forest that does not exist yet. The bit is resolved inside policy resolution, so every consumer, the primitives, the scoping, the console projections, inherits it and none of them can forget it.
How a Station gets its first identity
Three mutually exclusive doors, each explicit. Starting the server opens none of them by itself.
| Door | How it opens | How it closes |
|---|---|---|
| Setup screen | POST /v1/auth/setup is unauthenticated, and it is only safe because the condition for it is that the registry holds no credential at all: no password, no key, so there is no privilege to escalate from. The first person to open the console becomes the owner. Minimum password length is 12. | Permanently, the moment it succeeds. A closed route answers exactly like a path that never existed, because "already configured" would publish the deployment's state to anyone who asks. |
| Bootstrap key | station serve --bootstrap-key, or MONKEYLLM_STATION_BOOTSTRAP_KEY=1 for a platform UI with an environment table and no argv field. Mints one key carrying the owner bit and prints it once. | It spends the first-run window: setup is closed afterwards. It refuses to mint on a registry that already has a way in, so restarting with the flag cannot grow a second full-authority credential. |
| Environment account | MONKEYLLM_STATION_ADMIN plus MONKEYLLM_STATION_PASSWORD. Break-glass: the account is never stored in the registry and is rotated by changing the variables and restarting. | Configuring it closes setup, because the deployment has already declared its first identity. It has no stored password, so the admin password route refuses it by name. |
An unclaimed Station is a race
While setup is open, whoever reaches the console first becomes the owner. The first boot says so on standard output. Do not leave a publicly reachable Station sitting on that screen: use --bootstrap-key for a headless box, or the environment account for a deployment that has no browser in front of it.
The six capabilities
Capabilities are the vocabulary of every grant. There are exactly six, and the mapping from primitive to required capability is a single table in the enforcement layer, not a per-route decision.
Capabilities
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
read | capability | optional | none | locate, look, move, pick, scan, sniff, harvest, view, and the answer composite. |
query | capability | optional | none | query: read-only SQL against a dataset node's SQLite payload. Separate from read on purpose, because reading a summary and running an aggregate over a payroll table are not the same permission. |
write | capability | optional | none | plant and graft: create and edit nodes. Also the curate composite. |
tend | capability | optional | none | tend: a single INSERT, UPDATE or DELETE against a dataset payload. Never DDL, and WHERE is mandatory on UPDATE and DELETE. |
ingest | capability | optional | none | ingest: put documents through the Gardener. Uploading bytes is enough; naming a path on the host's own filesystem additionally requires admin. |
admin | capability | optional | none | Grant access, mint and revoke keys, bind models, read the audit log, rebuild indexes, take snapshots. admin implies every other capability on the forests it covers. |
The Studio console offers these as named levels, Reader, Analyst, Editor, Curator and Owner, which are presets over the same six, restated in plain words under the choice and freely fine-tuned afterwards. The API only knows the capabilities.
Grants: forest, branch, table
A grant binds one principal to one forest. It carries the capabilities, an allow list of branch prefixes, a deny list, and optionally a per-dataset table allowance. Resolved, it is a policy:
Policy(
forest = "handbook",
caps = frozenset({"read", "query"}),
allow = ("product/", "support/"), # ("",) means the whole forest
deny = ("product/roadmap/",), # wins over allow, at any depth
tables = {"finance/ledger": ("invoices",)},
)allowdefaults to the whole forest. Prefixes are normalised with a trailing slash, so a grant onprojects/cannot accidentally swallowprojects-secret/.denywins overallow, at any depth.- Ancestors are not implied. A grant on
product/does not include_index. The master index names every branch in the forest, so handing it out would defeat the grant. A scoped principal's world is the subtrees they were given, and/v1/metells them the roots to start from. tablesnarrows inside a dataset: the SQL is parsed for the tables it references and a statement naming anything else is refused before SQLite runs it.
/v1/admin/grantadminCreate or replace a principal's grant on one forest. Requires admin on that forest. Optionally mints a key in the same call.
Request
curl -sX POST https://station.example.com/v1/admin/grant \
-H "Authorization: Bearer $ADMIN_KEY" \
-H 'content-type: application/json' \
-d '{
"forest": "handbook",
"principal": "support-bot",
"caps": ["read", "query"],
"allow": ["product/", "support/"],
"deny": ["product/roadmap/"],
"tables": { "finance/ledger": ["invoices"] },
"issue_key": true
}'200
{
"principal": "support-bot",
"grants": [
{
"forest": "handbook",
"caps": ["query", "read"],
"allow": ["product/", "support/"],
"deny": ["product/roadmap/"],
"tables": { "finance/ledger": ["invoices"] }
}
],
"api_key": "mk_7Qd1…"
}Grants are per forest, and per person
The console applies a set of forests in one submission, but each forest is authorised, applied and refused on its own. An administrator of two forests out of three grants the two and is told, by id, about the third. Nothing is silently dropped, and half a submitted form is never quietly discarded.
A worked example
Take this forest, and the grant above:
handbook/
├── _index.md master index: names every branch
├── engineering/
│ └── oncall-rotation.md
├── finance/
│ └── ledger.md dataset: tables "invoices", "payroll"
├── people/
│ └── compensation-bands.md
├── product/
│ ├── pricing.md
│ └── roadmap/
│ └── 2027-themes.md
└── support/
└── refund-policy.mdlook("support/refund-policy") → the digest
look("product/pricing") → the digest
look("product/_index") → the digest, children filtered
locate("refund window", k=5) → only in-scope results
query("finance/ledger",
"SELECT * FROM invoices") → rows
# /v1/me reports where to start, because a scoped key has no
# master index to look at:
# "roots": ["product/_index", "support/_index"]look("_index") → E_NOT_FOUND
look("people/compensation-bands") → E_NOT_FOUND
look("engineering/oncall-rotation") → E_NOT_FOUND
look("product/roadmap/2027-themes") → E_NOT_FOUND
# Four different reasons, one answer:
# - the master index is an ancestor of a granted subtree, and
# ancestors are NOT implied: _index names every branch in the
# forest, so handing it out would defeat the grant;
# - people/ and engineering/ were never in allow;
# - product/roadmap/ is in deny, which wins at any depth.
#
# The envelope is byte-identical to a genuinely missing node:
{
"error": {
"code": "E_NOT_FOUND",
"message": "node not found: people/compensation-bands",
"hint": "Use locate() to find entry points."
}
}plant({"id": "support/new-note", …})
→ 403 {"error": {"code": "E_FORBIDDEN",
"message": "'plant' requires the 'write' capability",
"hint": "This principal holds: ['query', 'read']."}}
query("finance/ledger", "SELECT * FROM payroll")
→ 403 {"error": {"code": "E_FORBIDDEN",
"message": "table not permitted: payroll",
"hint": "This principal may read: ['invoices']."}}
# Why these say "forbidden" and the ones on the previous tab do not:
# here the caller supplied the id, or the node is already visible to
# them, so naming the refusal discloses nothing. An authorization
# error on an out-of-scope node would BE the disclosure.What scoping actually guarantees
Filtering results is the easy half. The hard half is that a filtered answer must not describe what was filtered, and that is where most access-control layers leak. Two invariants shape every scoped call.
No existence oracle. An out-of-scope read raises the engine's own E_NOT_FOUND, with the same message and the same hint a genuinely missing node produces. That holds through move too, whose edges would otherwise disclose a hidden neighbour, and through the trail on every search result, which is filtered rather than special-cased.
No truncation oracle. Filtering happens before the caller-visible cut, and every derived count is recomputed from what survived, because a count taken over the whole forest is itself a disclosure.
# The branch really has 40 children; this key may read 6.
look("product/_index")
children → 6 entries
coverage → "5 bananas, 1 sub-branches." ← recomputed, not the real 40
stats.degree → counts only visible edges
# The forest really holds 1,877 nodes; the sniff scanned all of them.
sniff(["refund"])
results → in-scope hits only
scanned_nodes → replaced by len(results), because the real number
is a forest-size oracle
# Ranking happens over an over-fetched candidate set and the filter is
# applied on the way out, so a scoped caller still receives a full k
# whenever there is enough headroom. That is what keeps the filtering
# invisible rather than merely enforced.Writes are the one place a refusal names itself: a plant outside the grant answers E_FORBIDDEN, not E_NOT_FOUND, because the caller supplied the id and nothing is disclosed by saying so.
Ingest is scoped like everything else
A scoped principal must say where documents go (dest), that destination must be inside their grant, and it must already exist as a branch: creating a new top-level destination would graft an entry into the master index, a node they may not even read. Naming a path on the host's filesystem additionally requires admin and that path being inside MONKEYLLM_INGEST_ROOTS, which is empty unless an operator set it. The registry's own volume can never be an ingest root, listed or not.
Keys
A key is mk_ followed by 32 random bytes, URL-safe. The plaintext is returned once, by the call that minted it, and never stored: lookup is by sha-256 digest, so a stolen registry file yields no usable credentials. What the registry keeps beside the digest is the metadata that makes a credential governable.
{
"id": "3f9a…", // the sha-256 digest: the revocation handle
"principal": "support-bot",
"label": "CI pipeline",
"created": "2026-08-01T10:22:41+00:00",
"prefix": "mk_7Qd1x", // the non-secret head, for recognising a row
"expires_at": "2026-11-01T10:22:41+00:00",
"revoked_at": null,
"last_used_at": "2026-08-14T09:41:07+00:00",
"status": "active" // active | expired | revoked
}The id is the digest, and it is the handle for revocation. It is not a secret, it is what a stolen registry already contains, and the key it came from cannot be derived from it. The prefix exists so a human can recognise a token in a list without it being disclosed. Session tokens never appear in this listing: they are the by-product of a sign-in, not a credential anyone manages.
Minting, and the whole-person rule
/v1/admin/keysadminMint a key for a principal, or revoke one by digest. Listing returns metadata for the principals this caller fully administers.
Request
curl -sX POST https://station.example.com/v1/admin/keys \
-H "Authorization: Bearer $ADMIN_KEY" \
-H 'content-type: application/json' \
-d '{"principal": "support-bot", "label": "CI pipeline",
"expires_in_days": 90}'200
{
"api_key": "mk_7Qd1x8vTn…",
"principal": "support-bot",
"keys": [ /* the principal's tokens, metadata only */ ]
}A key authenticates a principal, and a principal may hold grants on several forests. So minting or revoking one requires admin on every forest that principal holds, not merely on one of them. Otherwise the administrator of one forest could mint a credential that opens another.
{
"error": {
"code": "E_FORBIDDEN",
"message": "'support-bot' holds forests you do not administer",
"hint": "Minting a key for a principal needs 'admin' on every forest
it is granted, or the key would reach further than you can."
}
}The same rule governs seeing, not only issuing: a person who also holds a forest you do not administer still appears in your list, and their credentials are simply out of reach.
Paired keys narrow, never widen
An agent needs a credential that belongs to the person operating it, not one an administrator has to mint for every experiment. Pairing is that door. It is unauthenticated like sign-in, it takes a username and password, and it answers with a key. It needs no administrator because it reaches nothing the password could not already reach, and refusing it would only route the same authority through a wider credential.
POST /v1/auth/pair
curl -sX POST https://station.example.com/v1/auth/pair \
-H 'content-type: application/json' \
-d '{"username": "you", "password": "…", "label": "claude-code"}'200
{
"api_key": "mk_2Kf9…",
"principal": "you",
"caps": ["ingest", "read"],
"expires_at": "2026-11-13T09:12:00+00:00"
}Four rules make it safe to hand to a machine:
- The mask has a ceiling. A paired key carries a capability mask of at most
readandingest. Asking forwrite,tend,queryoradminis refused, not trimmed. - Grants intersected with the mask, at the moment of use. The mask is a filter over live authority, never a copy of it, so a grant revoked after pairing is gone from the key immediately. It can only ever subtract.
- It always expires. 90 days by default, 365 at most. Absent or zero means the default, never unlimited, and the ceiling is stated rather than silently clamped.
- The owner bit is masked too. A pair key held by the owner opens no admin console, exactly as if the bit were absent, and
/v1/mereports the masked capabilities so a console does not render buttons the key cannot press.
# Asking for more than the ceiling is refused, not trimmed:
{"username": "you", "password": "…", "caps": ["read", "write"]}
→ {"error": {"code": "E_SCHEMA",
"message": "pair caps must be within ['ingest', 'read']",
"hint": "write, tend, query and admin stay what People
and `station key` mint, deliberately."}}
# And a ceiling on lifetime that is stated rather than silently clamped:
{"username": "you", "password": "…", "expires_in_days": 730}
→ {"error": {"code": "E_SCHEMA",
"message": "expires_in_days must be at most 365"}}Both password doors are rate limited
Sign-in and pairing are both reachable from every browser that holds the origin, so both count failures in a fixed window per username and client host: five attempts per minute. Past the limit the answer costs nothing and says nothing about who exists, and the ordinary refusal is one message for a wrong password, an unknown user, and a user with no password at all. Distinguishing them would turn the login form into a directory of who exists.
Lifecycle and revocation
Revoked and expired keys fail at the single place every surface passes through, the credential resolution itself, rather than at some later gate. A lifecycle enforced anywhere else is a lifecycle with a bypass.
curl -sX POST https://station.example.com/v1/admin/keys \
-H "Authorization: Bearer $ADMIN_KEY" \
-H 'content-type: application/json' \
-d '{"revoke": "3f9a…"}'
# {"revoked": "3f9a…", "principal": "support-bot"}
#
# Authorized BEFORE the effect: revoking first and checking after would
# answer 403 while the token was already dead.last_used_at is written on every successful resolution. It is second-resolution and best effort rather than an audit record, because the audit log is next door and keeps the detail. Its job is to make an unused token safe to remove.
The audit trail
Two halves, each stored where it belongs. Reads land in the registry. Writes are already commits in the forest's own git history, so there is no second copy to keep honest.
Reads: the registry log
CREATE TABLE audit (
ts TEXT NOT NULL, -- UTC, second resolution
principal TEXT NOT NULL, -- who
forest TEXT NOT NULL, -- where
primitive TEXT NOT NULL, -- which call
args TEXT NOT NULL, -- digest only: never bodies or snippets
result TEXT NOT NULL, -- "ok" | "error" | "cache"
size INTEGER NOT NULL, -- bytes of the response served
commit_sha TEXT -- set for writes
);args is a digest, never the arguments verbatim: each value is kept only if it is 80 characters or shorter, and replaced by <N chars> otherwise. Bodies and snippets are never copied. The log records access, not content, and that is a deliberate limit rather than an omission.
/v1/admin/auditadminThe access log, newest first. Optional principal filter, limit capped at 500. Only entries for forests this caller administers are returned.
Request
curl -s "https://station.example.com/v1/admin/audit?limit=50&principal=support-bot" \
-H "Authorization: Bearer $ADMIN_KEY"200
{
"entries": [
{
"ts": "2026-08-14T09:41:07+00:00",
"principal": "support-bot",
"forest": "handbook",
"primitive": "pick",
"args": "{\"id\": \"support/refund-policy\"}",
"result": "ok",
"size": 2914,
"commit_sha": null
},
{
"ts": "2026-08-14T09:38:52+00:00",
"principal": "support-bot",
"forest": "handbook",
"primitive": "look",
"args": "{\"id\": \"people/compensation-bands\"}",
"result": "error",
"size": 141,
"commit_sha": null
}
]
}Three things worth noticing in that reply:
- Refusals are logged. The second entry is a read of a node outside the grant. It records
result: error, even though the caller was told the node does not exist. - The log is scoped like everything else. The route over-fetches and then keeps only the forests this caller governs, so a short page is a short page rather than a leak. An administrator of one forest does not read another forest's access history.
- A cached answer is audited as one. When a repeated question is served from the answer store, the row is marked
cacheand the cost it records is the cost avoided, never a second spend.
Writes: the forest history
Every plant, graft and tend is an atomic git commit inside the forest, and the Station stamps the acting principal into the message as a trailer. Because amending rewrites the sha, the response carries the new one: a reply naming a commit that no longer exists would be worse than no attribution at all.
$ git -C /forests/handbook log --format='%h %s%n%b' -3
3f9a1c2 plant(support/refund-policy-2026): Refund policy 2026 [source=agent]
station-principal: support-bot
8c14e0d tend(finance/ledger): INSERT 1 row(s)
station-principal: finance-sync
2d80fb3 graft(product/pricing): set summary; append 'Q3 change'
station-principal: aliceTogether the two halves reconstruct any answer after the fact: which principal, which primitives, which nodes, in which order, and what changed as a result. Note also what is not possible: there is no primitive that deletes a node, for an agent or for the Ranger, so the history is additive.
Model bindings as a governed setting
Which model answers questions about a forest is an administrative decision, not a client one, and it is stored beside the grants for the same reason. A binding is (forest, role) mapped to a provider, a model id, a reply length and a reasoning switch. There are four roles.
| Role | When it runs | Absent, what happens |
|---|---|---|
answer | On every question, reading retrieved material and writing the reply. Optimise for speed. | Everything except answer still works: navigation, search, datasets, ingest. |
ingest | Once per document, writing the summary every later search navigates by. Optimise for care. | Ingest still runs, writing factual template passports instead of curated ones. |
vision | At ingest, describing images so search can find them. Its description is all an image ever says to a text search. | Images still become media nodes, without a written description. |
embed | Not a chat model: it builds the vector layer and points the goal-directed frontier ranking. | Navigation is unchanged and search stays keyword only. |
/v1/admin/modelsadminBind a model to one role on one forest, or remove a binding. Requires admin on that forest specifically.
Request
curl -sX POST https://station.example.com/v1/admin/models \
-H "Authorization: Bearer $ADMIN_KEY" \
-H 'content-type: application/json' \
-d '{"forest": "handbook", "role": "answer",
"provider": "openrouter", "model": "qwen/qwen3-30b-a3b",
"max_tokens": 900, "reasoning": "off"}'200
{
"bindings": [
{ "forest": "handbook", "role": "answer", "provider": "openrouter",
"model": "qwen/qwen3-30b-a3b", "max_tokens": 900, "reasoning": "off" },
{ "forest": "handbook", "role": "ingest", "provider": "local-llama",
"model": "gemma-3-27b", "max_tokens": 600, "reasoning": "off" }
]
}# Providers are named endpoints. Any OpenAI-compatible /v1 base URL
# works: OpenRouter, LiteLLM, vLLM, a local llama.cpp.
POST /v1/admin/providers
{"name": "openrouter", "endpoint": "https://openrouter.ai/api/v1",
"api_key": "sk-or-…"}
# Listing never returns a secret, only whether one is stored:
GET /v1/admin/providers
{"providers": [
{"name": "openrouter", "endpoint": "https://openrouter.ai/api/v1",
"created": "2026-07-02T…", "origin": "console", "has_key": true}
]}
# A provider declared by the deployment's own environment arrives with
# origin "env", and its key is NEVER written to the registry: it is read
# back from the environment at call time. Change the variable and
# restart to change it.Binding a model never widens access
Retrieval runs inside the asker's scope before any model is called, and the composite is handed the scoped view rather than the raw forest. The model therefore only ever sees material that person could already have read primitive by primitive. Binding a model cannot become a way around the policy, which is why the binding is an administrative setting and not a request parameter.
Beside bindings, the registry holds per-forest switches that are not about models, currently the on/off for goal-directed ranking. They are a separate table on purpose: the next switch will not be about models either.
Evaluation checklist
The four questions a security review asks first, answered against the implementation rather than against intent.
Forests one directory per forest, markdown under git, on a
volume you control. Node bodies, passports, history.
Payloads dataset SQLite files beside their passport. Never
committed to git; referenced by payload_hash.
Derived _derived/ per forest: search catalog, pheromone, traces,
body cache, vector index. Disposable by definition and
rebuildable from the files.
Registry ONE SQLite file, deliberately outside every forest, holding
principals, key digests, grants, bindings and the audit
log. A forest handed to another operator carries no
credentials, because there are none inside it.
No external database is required, for any of it.Nothing, unless you configured somewhere for it to go.
There is no telemetry, no licence check and no call home. Search is
BM25 over a local index. The only outbound connections the code makes
are to endpoints you named yourself:
chat model MONKEYLLM_LLM_ENDPOINT, or a console-configured
provider bound to a forest role
embeddings MONKEYLLM_EMBED_ENDPOINT, entirely optional: with no
embedder, locate stays BM25-only
remote payloads only for a node whose payload URI is s3://, which
exists only if somebody wrote one
When a model IS bound, what is sent to it is exactly the retrieval the
asker was already allowed to read. Point the provider at a machine on
your own network and nothing crosses it.API keys 256-bit random tokens. Only a sha-256 digest is
stored, plus a 9-character non-secret prefix so a row
is recognisable in a list. A stolen registry file
yields no usable keys.
Passwords scrypt (n=2^14, r=8, p=1), 16-byte random salt per
principal, standard library only.
Provider keys write-only across every surface. The listing reports
whether one is set, never the value, and an empty
field on update keeps the stored one so an endpoint
can be corrected without re-pasting a secret.
Env providers their key is never stored at all.Deny by default no grant means no access. A principal with no
grant on a forest cannot tell you it exists.
No DDL, ever agents create tables only through the declarative
schema on plant. tend is DML only, WHERE is
mandatory on UPDATE and DELETE.
No deletes there is no primitive that deletes a node. Not for
agents, not for the Ranger.
Host filesystem reading a path on the host needs 'admin' AND that
path being inside MONKEYLLM_INGEST_ROOTS, which is
empty unless an operator set it. The registry's
own volume can never be an ingest root.
Everything is git every write is a commit; nothing is destroyed in
place, and 'git log' is the recovery story.One more, because it is the one people forget to ask: what happens when a grant is revoked while a key is in flight? The policy is read at the moment of use, on every call, and a paired key's mask is intersected with the live grants at that same moment. There is no cached authority to expire, so revocation takes effect on the next call rather than on the next token refresh.