retire the single-user claim from the docs it outlived

CLAUDE.md asserted "single-user is a hard invariant, not a stage" while
users held six rows and role_capabilities held grants. Every doc that
repeated it is corrected here, in prose and in the code comments that
carried the same claim.

The accurate statement is narrower: one owner who bypasses every check,
other accounts holding only what their role is granted, and a set of
capabilities — terminal, chat, files, tasks, items, desktop, browser — that
are structurally ungrantable because they execute as the owner's OS user.

TODO.md gains a Multi-user section for what the read turned up: no way to
create a second account, dashboards.id colliding across users, authorize.ts
untested, pty/vault/opencode taking no identity, Radicale still owner_only.

claude-sidecar-isolation.md's open question is answered rather than left
open — the per-email spawn model is dead weight, because chat is an
execution capability and no second account can ever reach it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 21:58:44 +00:00
co-authored by Claude Opus 5
parent ac64a7362b
commit d56be0301d
13 changed files with 227 additions and 75 deletions
+11 -6
View File
@@ -284,12 +284,17 @@ is a real design decision and I don't have a confident recommendation.
## Open questions — the ones I'd rather you answered
1. **Is the per-email spawn model dead weight?** `CLAUDE.md` states single-user is a hard invariant
("If a change seems to need 'which user is this', the answer is always the owner"), yet the agent
sidecar is keyed per email — `claude:${email}`, a `claudeProcs` Map, a `claudeSpawnWaiters` Map, a
per-email PID lock. If there is only ever one owner, Stage 1a becomes trivial: one PM2 entry, no
fan-out, no registration polling. If you intend multi-tenant later, the fan-out has to stay and
Stage 1 gets harder. **This single answer changes the shape of the whole plan.**
1. ~~**Is the per-email spawn model dead weight?**~~**answered 2026-08-07: yes, it is.** The
question was whether multi-tenancy might later need the per-email fan-out (`claude:${email}`, the
`claudeProcs` and `claudeSpawnWaiters` Maps, the per-email PID lock). The capability model settled
it in the *other* direction from what "the platform is going multi-user" would suggest: `chat` is
`kind: 'execution'` in `capabilities/registry.ts`, which is **never grantable at any level**,
because the agent runs as the owner's OS user with `--dangerously-skip-permissions`. Additional
accounts exist now, and not one of them can ever open a chat.
So the fan-out is keyed on a dimension that is structurally guaranteed to have one value. Stage 1a
is the trivial version: one PM2 entry, no fan-out, no registration polling. This only reopens if
per-user home confinement is ever built, which is a project rather than a checkbox.
2. **Relay or redirect?** Officer proxies the agent WebSocket (one origin, keeps your HTTPS reverse
proxy and JWT model intact, but a restart still drops the socket for a moment), or officer hands
+2 -1
View File
@@ -117,7 +117,8 @@ worth serving both from one place.
- **It is not backup.** Sync propagates deletions. A synced folder is not a backup of itself, and
anyone who believes otherwise finds out at the worst moment. Versioning (Syncthing has several
strategies) should be enabled and surfaced in the UI precisely so this is not confused.
- **It is not sharing.** Single-user remains a hard platform invariant.
- **It is not sharing.** Files is an `execution` capability — the owner's disk, never grantable — so
there is still nobody to share with, whatever the account list says since 2026-08-07.
---
+5 -2
View File
@@ -9,8 +9,11 @@ sidecar with its own scheduling, so it does not appear in the Jobs list.
created over REST, streamed live over WebSocket, resumable/attachable, visible on desktop *and* phone,
and ending in a push notification. Replaces today's ephemeral script-task WebSocket path.
**Context:** single user, forever. No multi-tenant concerns — "is anything running?" is a global check.
Favor power-user affordances over guardrails. See memory `sole-user-assume-competence`.
**Context:** jobs belong to the owner. Not because the platform is single-user — it stopped being that
on 2026-08-07 — but because `tasks` is an `execution` capability: running a job means running a script
as the owner's OS user, so it can never be granted to a member. "Is anything running?" is therefore
still a global check, and the conclusion below is unchanged even though the premise was rewritten.
Favor power-user affordances over guardrails.
## Current state (baseline)
+9 -4
View File
@@ -58,7 +58,9 @@ speaks DAV.
### 1.2 The credential model
- **Username** = the account's email address. (Officer is single-user; there is exactly one.)
- **Username** = the account's email address — the signed-in account's own, not a constant. (This said
"Officer is single-user; there is exactly one" until 2026-08-07. `calendar` is now a grantable
capability, so a member can hold their own app passwords and their own collections.)
- **Password** = a **DAV app password**, not the login password.
DAV app passwords are argon2-hashed at rest, scoped to `/dav` and nothing else, and **the plaintext is
@@ -100,9 +102,12 @@ A collection https://<host>/dav/<userId>/<collection>/
`<userId>` **is the platform user id, by construction** — the same integer `/auth/me` returns. They
cannot diverge: `sync-router.ts` sets `X-Officer-User: String(userId)` straight from the app-password
row, the sidecar forwards it to Radicale as `X-Remote-User`, and Radicale's storage tree is literally
`/<that value>/`. There is no mapping table to get out of step. On a single-user instance — which every
Officer instance is — that is `1`. Deriving it from `/auth/me` is safe; so is deriving it from the
collection paths, which is why both work today.
`/<that value>/`. There is no mapping table to get out of step.
**Do not hardcode `1`.** This passage used to say that on a single-user instance — "which every Officer
instance is" — the value is always `1`. That stopped being true on 2026-08-07: members can hold the
`calendar` capability, and a member's id is not 1. Derive it from `/auth/me` or from the collection
paths; both work, and both stay correct when the caller is not the owner.
**A collection cannot live outside `/dav/<userId>/`.** Two independent guards: the sidecar rejects any
`collection` outside that prefix, and Radicale runs `rights type = owner_only`.
+6 -4
View File
@@ -135,8 +135,9 @@ Rationale for not reusing the account password: it ends up typed into a phone, s
account manager in recoverable form, and synced to whatever backs that phone up. One password per
device, revocable per device, is the whole point.
The single-user invariant holds — every app password belongs to the owner. `user_id` is there for
referential integrity, not multi-tenancy.
`user_id` was described here as "referential integrity, not multi-tenancy". That is no longer true:
since 2026-08-07 `calendar` is a **grantable** capability, so an app password can belong to a member
and the column decides whose collection tree Radicale serves. It is load-bearing.
---
@@ -182,8 +183,9 @@ Naming these now so they do not creep in later:
- **iTIP/iMIP scheduling** — sending invitations and processing RSVPs by email. Genuinely complex, and
a single-user personal calendar mostly consumes invitations rather than issuing them. Revisit only
on a concrete need.
- **Sharing, ACLs, federation** — single-user is a hard invariant of this platform. There is nobody
to share with.
- **Sharing, ACLs, federation** — still out of scope, but the reason weakened on 2026-08-07. Members
can now hold `calendar`, so there is somebody to share with; what is missing is any notion of one
account granting another access to its own collection. Revisit on a concrete need.
- **Reimplementing RRULE on the server.** The sidecar stores what the client sends. Expansion happens
where it is displayed, using a library.
- **A NextCloud-compatible API.** Nothing needs to pretend to be NextCloud. The standards are the
+14 -4
View File
@@ -13,10 +13,20 @@ officer/
└── data/ runtime state — NOT version controlled
```
Officer is a self-hosted personal platform: an AI agent, a terminal, a file browser, a code editor,
email, a bitcoin wallet, a remote desktop and dashboards, behind one web app. **It serves exactly one
person — the owner of this server.** There is no tenancy, no roles, no other users. If a question
turns on "which user", the answer is the owner.
Officer is a self-hosted platform: an AI agent, a terminal, a file browser, a code editor, email, a
bitcoin wallet, a remote desktop and dashboards, behind one web app. **It is built around one owner**
— user id 1, role `Super Admin`, who bypasses every permission check — and since 2026-08-07 also
admits **additional accounts holding a strict subset of it**, governed by per-role capability grants.
So "which user" has two answers depending on the surface. For the **app** capabilities (gitea, music,
photos, email, calendar…) it is a real question with a real answer. For anything that executes code or
touches the disk — terminal, chat, tasks, files, desktop, browser — it is still always the owner:
those are `kind: 'execution'` in `platform/src/servers/capabilities/registry.ts` and can never be
granted, because they run as the owner's OS user in the owner's home.
This paragraph said "there is no tenancy, no roles, no other users" until 2026-08-07. Four roles exist
and five non-owner accounts are live; treat the capability registry as the source of truth over any
prose, here or elsewhere.
`platform/` and `capabilities/` each have their own `CLAUDE.md` with detail. This file is the layer
above them: where things live, how to change them safely, and the things that are true of the running