# Scoped access: one permission model for people and agents Status: proposal. Builds on ADR 0013 (grants are renewable leases), ADR 0014 (a role is conferred, not claimed), ADR 0016 (one seed, every identity derives), and `DASHBOARD_TEAM_ACCESS_MODEL.md`. Nothing here replaces those; this document names the vocabulary they left open and says where it is enforced. ## The two situations this has to serve 1. **A software team.** Redaktly wants a salesperson who can see task status, read the conversation about a customer's request and message the orchestrator, and nothing else. It wants a contract developer who can take and submit work on one feature, review nothing, and never see the vault. 2. **A law office.** A firm runs its own Ferryman server. Attorneys, paralegals and staff each get an identity. Access is per matter, read or write. An attorney's agent drafts inside the matters that attorney can see; a paralegal's agent can read a matter but never approve anything. The partner who runs the server can answer, from the ledger, who could see what on any given day. These are the same problem. The second one is only the first one with a compliance officer reading the audit trail. ## What the repository already has - Every principal is an ed25519 key. A human operator (`operators.rs`) and an agent (`AgentIdentity`) derive from the same seed and sign the same way. The channel does not care which one is holding the pen. **This is the whole foundation and it is already built.** Do not add a second identity type. - `MasterGrant` carries `projects`, `roles`, `capabilities`, master-signed. - `LeaseToken` carries `scope`, `resource`, `grant_id`, an expiry, renewal, revocation, and a ledger entry for each - ADR 0013. - `is_granted(route, grantee, role)` gates whether a worker may take work when `grants = "required"`. - A hash-chained ledger. ## What is missing Three things, and the third is the one that matters. 1. **No vocabulary.** `capabilities` is `Vec` and the values in use are `messages.receive`, `issue`, `build`, `mock`. Nothing can be checked consistently because nothing agrees on what the words are. 2. **No binding between a person and their agents.** A grant to the sales agent and a grant to the salesperson are unrelated files. Nothing stops the agent from holding more than its human. 3. **No enforcement at the dashboard.** `require_session` is yes/no. Any operator who can log in can call every one of the thirty `/api/*` routes: read and set vault secrets, approve a release, issue grants to others, invite teammates. The sales guy is an admin the moment his password works. ## Decision ### 1. A scope is `resource:action[:selector]` Scopes are strings so they keep fitting in the existing `capabilities` and `scope` fields, sign under the existing payloads, and stay readable in a ledger years later. The grammar is fixed: ``` : applies to every instance of the resource :: applies to one named instance :* every action on the resource ``` Resources and actions (the initial set; additions are a one-line change to `SCOPES` and nothing else): | resource | actions | selector | |-----------------|-------------------------------------|-------------------| | `tasks` | `read`, `create`, `take`, `submit`, `review` | task id or label | | `conversations` | `read`, `write` | topic | | `memory` | `read`, `propose`, `approve` | - | | `ledger` | `read` | - | | `agents` | `view`, `message`, `assign`, `handoff` | agent name | | `secrets` | `list`, `use`, `set`, `remove` | secret id | | `release` | `read`, `approve` | - | | `team` | `read`, `invite`, `grant`, `revoke` | - | | `fleet` | `read` | - | | `cost` | `read`, `plan` | - | A scope always sits inside a project. `projects` on the grant already says which; `*` there means business-wide. So "read the redaktly tasks" is the pair (`projects: [redaktly]`, `tasks:read`) and it cannot be misread as anything broader. Matching is exact on resource and action, and a selector on the *required* side must equal the selector on the *held* side or the held side must have none. `secrets:use:stripe-live` satisfies `secrets:use:stripe-live`; `secrets:use` satisfies it too; `secrets:use:stripe-test` does not. No regex, no hierarchy, no inheritance. If a check needs cleverness the vocabulary is wrong. ### 2. A role is a named bundle of scopes, expanded when the grant is written Operators will not type scopes. They pick a role in the dashboard, and the role expands to scopes **before signing**, so the stored grant and the ledger carry the scopes, never the role name. Two consequences: a role's definition can change tomorrow without silently changing what was granted yesterday, and an auditor reads the grant file and needs no other document. Built-in roles, deliberately few: | role | scopes | |-------------|------------------------------------------------------------------------| | `owner` | `*:*` in `projects: ["*"]` | | `admin` | everything except `secrets:set`, `secrets:remove`, `team:grant`, `team:revoke` | | `developer` | `tasks:*`, `conversations:*`, `memory:read`, `memory:propose`, `ledger:read`, `agents:view`, `agents:message`, `fleet:read` | | `reviewer` | `developer` minus `tasks:take`, `tasks:submit`; plus `tasks:review` | | `viewer` | `tasks:read`, `conversations:read`, `memory:read`, `ledger:read`, `agents:view`, `fleet:read` | | `worker` | `tasks:read`, `tasks:take`, `tasks:submit`, `conversations:read`, `conversations:write`, `memory:read`, `memory:propose` (this is ADR 0014's floor, unchanged) | `viewer` plus `agents:message` is the Redaktly salesperson. A law office adds `attorney` = `reviewer` + `memory:approve`, `paralegal` = `developer` minus `tasks:review`, `staff` = `viewer`, in a `roles.toml` in the master folder. Custom roles are the firm's business; Ferryman ships the six above and reads the file. `temp` / `contractor` / `employee` from ADR 0014 stay exactly what they are: the *engagement* picks the lease horizon and project breadth; the *role* picks the scopes. Two axes, two words. ### 3. An agent's authority is attenuated from its owner's This is the rule that makes "extends to the agents" true instead of hopeful. - Every agent has an **owner**: the principal that issued its lease. - **Who owns a business agent is the business's decision, not Ferryman's.** The master is the *default* owner of a business agent because on day one it is the only principal that exists, but the master may assign ownership to any operator (a managing partner, an IT lead, a practice-group head) by a signed record in the master folder, and may reassign it the same way. Ferryman defines what an owner *can do* (issue attenuated leases, renew, revoke) and records who it is; it never decides who it *should* be. The dashboard shows the owner on every business agent and refuses to install one without naming one. - A principal may issue a lease to an agent only for scopes it currently holds itself, in projects it currently holds. `issue_grant` enforces this by reading the issuer's own effective scopes and refusing any excess. **No agent can hold a scope its owner lacks.** - When the owner's grant shrinks or expires, the agent's leases are not reissued past the owner's horizon. The dashboard renews an agent's lease only while the owner's own lease is live, so the attenuation holds over time, not only at issuance. - The master keeps the existing power to grant directly; that is the one place authority enters the system from outside. This is capability attenuation in the sense of macaroons or Biscuit, without importing either: the "proof" is the chain of signed lease files already in the channel, and the attenuation check is a set comparison at issue time. For the law office this reads: the paralegal's drafting agent can read the Smith matter because the paralegal can, cannot approve a memory entry because the paralegal cannot, and loses the Smith matter the day the paralegal is taken off it. Nobody has to remember the agent. ### 3b. Master is a person; primary agent is a designation Two roles that were one word in the code, and should not be. **The master is a person.** The root of trust is an operator key, held by a human, carried between machines, sealed under their password. A machine agent can be declared master implicitly on a first machine so setup does not stall (`ferry enable`), but the moment a person exists on that machine the role is transferred to them, signed by the agent, so the chain reads "declared, then disclaimed" and never "seized". The dashboard offers *I am the master of this project* whenever no master exists, signed with the session's already-unlocked key - no password typed twice. **The primary agent is a designation the master signs and can move.** It names which agent currently orchestrates: issues work, reviews, holds the fleet's attention. It is exactly Marvin's *holder* (ADR 0017) with one addition: the master can move it **on the fly**, from the dashboard, without waiting for the old holder to go quiet. The trigger is usually tokens - the primary is out, and the fleet must not stall for the quiet-timeout - so the switch is one click: *Make grouchly primary now*. The designation is a lease (`role: primary`, scope `agents:assign`, `tasks:review`, `tasks:create`, `memory:approve` across the project, short horizon, renewed while the holder's brief keeps moving). Moving it: 1. writes a `primary.json` naming the new holder, signed by the master; 2. the outgoing holder, if alive, sees it and calls `marvin release` with a note; if it is not alive, the brief it was writing continuously *is* the handoff (ADR 0017's whole point); 3. the incoming holder calls `marvin take`, runs `marvin resume`, and opens its context with the outgoing brief; 4. the ledger records the switch with both names and the reason. **The primary's memory is its own, on top of project memory.** Project memory (`memory/`, approved by proposal) is what the fleet agrees is true. The primary's brief is what *this orchestrator* is doing about it: the objective, what is in flight and why, decisions not worth an ADR, what is waiting on the human, what was tried and rejected. It is per-holder (`marvin/brief..json`), written continuously, and read by the successor. Switching the designation never merges the two: the successor reads the predecessor's brief and starts its own. **The master switches it from anywhere.** The designation is a signed file in the channel, and the channel is everywhere the master is. Nothing is done *on* the new primary's machine: the master writes `primary.json` from whichever device holds the master key - the beastly dashboard, a laptop with the seed restored (ADR 0016), or the phone through Telegram (ADR 0008: the order is a signed file too, bound to a hash of exactly what was said) - Syncthing carries it, and grouchly's own agent loop sees it on its next poll and takes the brief. The old primary sees the same file and releases. A master who is away from every keyboard but the phone can still say *make grouchly primary* and have it happen. **The primary reports; silence is the signal.** Rather than every machine polling faster to notice a switch, the primary writes a short signed **report** on a fixed cadence (`marvin/report..json`, one writer, like everything here): when, what it is doing, the brief's age, and the engine's token or quota state where the engine exposes one. That is the deadman pattern (`ferry-deadman`): liveness is something the holder proves, never something a peer probes. Three consequences: - *Missed reports mean gone.* Marvin already treats a holding that has gone quiet as abandoned; the report gives "quiet" a clock. Two missed reports (the cadence is the holder's to declare in the report, default five minutes) and the designation is open. - *Failover is ordered, not a race.* The master's `primary.json` names the primary **and a standby list**. When the primary's reports stop, the first standby that is itself reporting takes the brief, signs its own holding, and the ledger records "took over from X after N minutes of silence". Nobody else moves. If the standby is also silent, the next one. Every agent on the roster with the `primary` scope is reporting anyway, because reporting is how it stays eligible. - *The master sees it coming.* A report carrying "12% of context left" or "quota resets 14:00" is on Home before the silence starts, next to *Make … primary now*. Most switches should be the master's choice, made early, from the phone; the automatic one is the floor under that, not the plan. The manual switch and the automatic one write the same file shape and leave the same ledger line, so a reader never has to know which happened. The dashboard shows the primary on Home with its last report, brief age and token state, and a *Make … primary* control next to every reporting agent. The Telegram topic for the project accepts the same instruction in words and posts a line when a failover happens. A firm that wants two primaries (one per project) has two designations, one per project. ### 4. Three enforcement points, no more | where | today | after | |---------------------------|---------------------------------|---------------------------------------------------------| | dashboard `/api/*` | logged in or not | a route table: each route names the scope it requires; middleware resolves the session's effective scopes and fails closed | | channel `permits` / `is_granted` | recipient + role string | `permits(principal, scope)`; `is_granted` becomes a call into the same resolver | | secrets broker | proposed, unwired | `secrets:use:` at use time, with the recipient-bound transport rules unchanged | One function answers all three: `effective_scopes(route, principal, at) -> ScopeSet`, which reads the principal's valid master grant, every unexpired unrevoked lease naming it, and (for agents) intersects with the owner's set. It is pure, it takes a timestamp, and it is unit-tested against a table of grant files. Every enforcement point calls it and nothing else does its own reading of grant files. The dashboard route table is the highest-value piece and the smallest: thirty routes, one scope each, in the file that defines the router. A route missing from the table does not fall through to "allowed"; the middleware refuses it and CI has a test that every registered route is in the table. ### 5. Read and write are what they say `read` scopes never mutate. The dashboard's read-only mode (a router built without signing state) already exists; a session holding only `*:read` scopes gets the same router. This is how a law-office viewer is guaranteed harmless without auditing every handler. ## What this deliberately does not do - No permission hierarchy, no role inheritance, no groups-of-groups. Groups are a dashboard convenience that expands to individual grants at write time, the same way roles do. - No central authority at use time. Every check reads local files, per ADR 0013. Stale copies self-extinguish at their horizon. - No change to signatures. Scopes are strings in fields that already exist and already sign. Existing leases verify unchanged; `messages.receive` is mapped to `conversations:read` by the migration below and nothing else is touched. - No opinion on organisational structure. Ownership, custom roles and groups are all data the business writes; Ferryman ships defaults so the first day works and reads the business's choices from then on. - No multi-server federation. A firm is one master, one server, one channel set. Two firms sharing a matter is a later problem and this design does not preclude it. ## Migration - On first run with a `SCOPES` table present, every existing grant and lease is read as-is; legacy capability strings map through a fixed table (`messages.receive` -> `conversations:read`, `issue` -> `tasks:create`, `build` -> `worker`), and unknown strings are kept and ignored by the matcher, never rejected. Nothing stops working. - Every operator on the roster is auto-granted `owner` by the master, once, signed, in the ledger - the same shape as ADR 0014's migration, because the alternative is locking out the people who already run the place. The operator then narrows from the Teammates page. - The dashboard's route table ships with every route mapped; a first release can log denials without enforcing (`scopes = "audit"` in `bridge.toml`) so an operator sees what would break for a week before `scopes = "required"`. ## Order of work 1. `scopes.rs` in `ferryman-channel`: the grammar, the matcher, `SCOPES`, built-in roles, `effective_scopes`. Pure, tested, no I/O beyond reading grant files through the existing lease/master functions. 2. Attenuation in `issue_grant` and in the dashboard's renewal path. 3. The dashboard route table and middleware, `audit` mode first. 4. `roles.toml` in the master folder for custom roles (catalogue and profiles in `SCOPE_PROFILES.md`, `examples/roles/`). 5. Teammates page: pick a role, pick projects, pick an engagement; the page shows the expanded scopes before the operator signs. Agents page: the same for an agent, with the owner's scopes shown greyed as the ceiling. 6. Flip the default to `required` one release later, migration included. Steps 1-3 are the product. Steps 4-6 are the law office.