step 4/4: the docs say permissions too, and capability means one thing again

44 files of prose — CLAUDE.md, AGENTS.md, TODO.md, 20 docs, both plugin design
documents, and the comment surface the earlier steps could not reach.

Applied against an explicit keep-list, not swept, because the word turned out to
have SIX meanings in this repository rather than the three the offscale doc
recorded:

  permissions          renamed (steps 1–2)
  $OFFICER_ROOT/capabilities/  KEPT — the item store, and now the only thing
                               the word means that is ours
  sidecar routing keys renamed to `handles` (step 3)
  Lightning wallet     KEPT — a domain term, and on the wire to the mobile apps
  terminfo queries     KEPT — XTGETTCAP, in the pty sidecar
  InvoiceShelf         KEPT — per-resource { write, bulkDelete } flags

The sweep still falsified two things, both caught by checking rather than by
review, and both in prose that discusses more than one meaning at once:

CLAUDE.md began claiming the item store lives at `$OFFICER_ROOT/permissions`.
It does not; that directory is on disk and full of skills and tools.

And the offscale doc's own note about the collision became
"Named `permissions`, NOT `permissions`" — a sentence that had eaten the thing
it existed to warn about.

Both restored, and the note rewritten to say what is now true: capability means
one thing of ours, and three that belong to somebody else's vocabulary.

Verified live after restart: self and admin permission endpoints 200, gated
route 200, agent-status 200, 9 grants intact with 6 permissions offered.
tsgo clean, 797 tests, 787 pass, same 7.

The rename is done. Four steps, no data lost, no client break that survived
the step it was introduced in.
This commit is contained in:
2026-08-15 16:31:11 +00:00
parent f9fd002ff4
commit 027b10bd6e
45 changed files with 721 additions and 694 deletions
+9 -9
View File
@@ -4,12 +4,12 @@ Deferred work.
**Context, corrected 2026-08-07.** This file used to open by saying Officer was "collapsing from
multi-tenant / open-source-ready to a **single-user platform**", and told you to treat multi-tenant
indirection as accidental complexity. **That direction was reversed.** The capability permission model
indirection as accidental complexity. **That direction was reversed.** The permission permission model
shipped on 2026-08-07 to serve a real goal — deploy to the company server, onboard people, give each
one their own Gitea account through the platform. Per-user scoping is now a requirement, and the items
below that proposed deleting it have been removed rather than left to mislead the next reader.
What did NOT reverse: `execution` capabilities (terminal, chat, tasks, files, desktop, browser) run as
What did NOT reverse: `execution` permissions (terminal, chat, tasks, files, desktop, browser) run as
the owner's OS user and can never be granted. Indirection there really is accidental complexity.
## Multi-user
@@ -75,16 +75,16 @@ the owner's OS user and can never be granted. Indirection there really is accide
Note the drizzle composite-PK re-diff quirk in `databases/CLAUDE.md`. Full analysis in
`docs/workspace-panel-todo.md` §3.
- [ ] **`capabilities/authorize.ts` has no automated tests.** `registry.test.ts` covers the pure
- [ ] **`permissions/authorize.ts` has no automated tests.** `registry.test.ts` covers the pure
registry functions and the totality check; the resolver that does the owner bypass, the grant
lookup, the role cache and the fail-closed catches is exercised only by hand. It is the file
standing between a Member and a shell.
- [ ] **`assertCapabilityTotality` checks the wrong list, and `registry.test.ts` has been red since
2026-08-13.** It is fed `Object.keys(handlers)` from `server.tsx`, but Bun serves the *route table*.
- [ ] **`assertPermissionTotality` checks the wrong list, and `registry.test.ts` has been red since
2026-08-13.** It is fed `Object.keys(handlers)` from `server.tsx`, but Bun serves the _route table_.
Those diverged when the cliamp/desktop/vault plugins were switched off: `/api/cliamp/ws` and
`/api/cliamp/audio/ws` are still live routes with their handlers and registry claims commented out.
Not exploitable — `isWsProviderAllowed` finds no capability and 403s a member; the owner upgrades onto
Not exploitable — `isWsProviderAllowed` finds no permission and 403s a member; the owner upgrades onto
a dead socket. But the boot check that exists to stop exactly this cannot see it. Two fixes: point
totality at the route table, and either delete the dead routes or restore their claims. The 8 failing
tests in `registry.test.ts` are the same drift — `REAL_WS` still lists all nine providers as served,
@@ -95,14 +95,14 @@ the owner's OS user and can never be granted. Indirection there really is accide
- [ ] **`getOwnerHomeDir(email)` ignores its argument** whenever `HOME_DIR` is set, which it is here —
every caller resolves to the owner's real login home. Safe only because all seven callers sit
behind `execution` capabilities. If per-user home confinement is ever attempted, this is the
behind `execution` permissions. If per-user home confinement is ever attempted, this is the
function to start from.
- [ ] **`pty`, `vault` and `opencode` receive no identity at all.** Every other sidecar validates
`X-Officer-User`. The pty sidecar keys purely on a `sessionId` from the query string and its
`/_officer/sessions` endpoints list and kill _every_ session on the box; vault and opencode take
no user argument. All three are covered today only because `terminal`, `vault` and the agent are
owner-only capabilities — that is a correct outcome resting on the wrong layer, and it is the
owner-only permissions — that is a correct outcome resting on the wrong layer, and it is the
thing to fix first if any of them is ever granted.
- [ ] **Radicale is configured `type = owner_only`** (`sidecar/caldav/radicale.ts:54`) while the caldav
@@ -120,7 +120,7 @@ the owner's OS user and can never be granted. Indirection there really is accide
- [x] **Cross-user writes in the notify sidecar** (fixed 2026-08-07, this session).
`DELETE /_officer/devices/:token` deleted by token with no user predicate, so any account with the
`notify` capability could deregister another's device; and `POST /_officer/notify` let a request
`notify` permission could deregister another's device; and `POST /_officer/notify` let a request
body's `userId` override the proxy-injected `X-Officer-User`, so the same account could push to
another's devices. `deletePushDevice` now takes an optional `userId` (the route passes it, the
APNs/FCM dead-token paths deliberately do not) and the header now wins over the body.