# Permissions, quotas and sharing Read this before touching `security/permissions.py`, `services/sharing.py`, `services/usage.py`, or the admin user and group screens. ## The union rule, and what it costs Permissions are a flat set of named booleans: a baseline, widened by each group. **A group grants; it never denies.** That is a recorded decision and the reason still holds — with denies, "why can this person not do X" needs a simulation of every group they are in. `permissions.explain(db, user)` is `resolve`'s working *shown* rather than thrown away: for each key, whether it is on and what granted it — "admin", "baseline", or the names of the groups. The user detail page renders it read-only, because every one of those switches is set somewhere else and a control there would be a third place to change one thing. ## Read and write, split for three gates `tools.notes` used to be one switch over five tools. Three gates now have a second permission, `tools..write`, listed in `permissions.SPLIT_GATES`: notes, memory, skills. It is checked in `resolve_tools`, not in `_family_allowed`, and that is not tidiness: `_family_allowed` is given a *family* and this needs the *tool*, since the whole point is that two tools in one family get different answers. It applies **after** the gate, so it can only narrow what was already allowed, and all three default on — an instance that never looks behaves exactly as it did. Not split everywhere. `web_search` has no write half; `report` is a write with no read worth withholding; `agent` has modes, which are finer than a permission and are per chat. A permission whose answer is always "the same as that one" is one nobody should be asked about. ## Quotas are the union rule applied to numbers `Group.limits_json`, resolved by `permissions.limits_for`. Five axes, because they fail differently and a single "budget" would need an exchange rate between a token and a minute of somebody's GPU. Three rules, and the third is the one that is easy to get wrong: 1. **Maximum across groups** — a second group can only ever grant more. 2. **Absent contributes nothing** — a group with no opinion about tokens must not silently make somebody unlimited. 3. **Zero means no limit and wins outright.** A plain maximum would make a group saying "unlimited" count for less than one saying "a million" — the union rule inverted for exactly the value somebody sets when they mean *stop limiting this person*. The same asymmetry appears wherever a group's ceiling meets the instance's, so `generation._narrower` is written once: it is not `min`, because a zero on either side would win and turn "no opinion" into "no time at all". Administrators are unlimited, for the reason they hold every permission. ### Where each is enforced, and why there | axis | where | why there | |---|---|---| | `monthly_tokens` | start of `generation._run` | knowable in advance; a reply that trailed off mid-sentence because a month ran out is the failure `_wrap_up` exists to prevent | | `concurrent_replies` | `api/chats.py:_send` | the only place with somebody to tell — a schedule firing has nobody at the keyboard | | `agent_seconds` | `_run`, narrowing `Limits` | the instance's ceiling already lives there | | `images_per_day` | `images/tool.py:run` | before a minute of GPU is spent | | `helpers_per_reply` | `subagent._run_subagent` | beside the instance's own per-reply cap | `concurrent_replies` is in-process, and that is exact **only because this application runs one worker**. With several it becomes a guess, and a quota that is a guess should be a number in the database instead. ## Usage is recorded even when the reply failed `generation._persist` is the single writer for everything a reply produced, and it records usage whether the reply finished, was stopped, or errored. An endpoint charges for tokens it generated regardless of whether anybody wanted them, and a quota that only counted happy paths is one a Stop button walks past. One row per user per period, UTC. Not the reader's timezone: a quota that reset at a different instant for each member of a group is one nobody can reason about. `usage.record` never raises — bookkeeping that broke a reply would be worse than no bookkeeping. `images_today` is counted off `Attachment` rather than kept as a counter, because there is a natural source of truth and a *daily* counter would need a second row shape and a second reset. ## Nothing cascades to a `Share` `Share.principal_id` points at a user *or* a group, and `resource_id` at one of four tables, depending on a sibling column. SQLite cannot express either as a foreign key, so **every delete has to say so explicitly**: - `delete_group` → `forget_principal(GROUP, id)` - `delete_user` → `forget_owner(id)` **and** `forget_principal(USER, id)` - deleting a resource → `forget_resource` `forget_principal` existed for exactly this and was called by nobody. `forget_owner` is new and is the half nothing else could catch: their rows cascade when the account goes, and the shares *of those rows* have nothing to cascade from. Both run **before** the delete, while the rows are still findable. ## Reports are shareable; memories are not A report is read once and never answered, so sharing it has none of the two-editors problem that keeps writing off the table. A memory is a record *about a person*, which is not content to hand round — that decision stands. `reports.visible` became `sharing.visible_to` — one line, which is what its own docstring predicted. Two consequences that needed saying: - `reports.owned` exists beside `get`. Sharing grants **reading**, so deleting is the owner's alone. Two functions rather than a flag, because a route that wants one and calls the other is a bug you can see in the name. - **Reading somebody else's report does not clear their dot.** `unread` is the owner's notification, and a reader opening it would silence something meant for a person who has not seen it. ## The share panel is its own action It used to be checkboxes inside the resource's save form, listing every group and every account on the instance, unpaginated, on every detail page — and a tick only took effect if the resource happened to be saved afterwards. Now: - `api/sharing.py` serves the panel and takes **one grant per POST**, answering with the panel again, so what is on screen is what is stored. - It searches. Anything already shared stays listed whatever the search says, or the only way to remove a grant would be to search for the name it was given to. - A principal id that names nothing is refused — a crafted one would write a grant invisible in the panel and unremovable from it. - Only the owner may reach any of it, checked with `sharing.can_write` (ownership, nothing else). A 404 rather than a 403: somebody who cannot share it has no business learning whether it exists. `library.share` **defaults on** now. It was off, which meant sharing shipped documented as done and unreachable — the panel only renders for somebody holding it, so out of the box nobody could share anything and nothing said why. ## Sharing still grants reading only Recorded, and the reason still holds: two editors, no history, no merge. Writable shares would touch `owned_by`, `can_write` and four places in `canvas.py`. Not for 1.0.