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
+32 -31
View File
@@ -9,7 +9,7 @@ reasoning is most useful next to the code it produced. The platform's own docs s
history of something it no longer knows exists.
Offscale is Headscale extracted into a plugin. It is the pilot: chosen because it is a genuine vertical
slice (schema + backend router + sidecar + frontend screen + capabilities) without being pathological.
slice (schema + backend router + sidecar + frontend screen + permissions) without being pathological.
**The name is not a rename.** Offscale is Headscale _plus the Companion_ — an API and UI that ship beside
the Headscale server and add what Headscale itself does not do, the invite flow being the first of them.
@@ -54,7 +54,7 @@ Combined per plugin as needed. **Only `meta` and the ID are always required.**
- what it stores in the **secret store**, and whether that is per-user or plugin-global
- a **frontend router**, its routes, and the frontend code
- how it **mounts into the file browser context menu**
- a set of **capabilities added to officer-items**
- a set of **permissions added to officer-items**
- **plugin settings page** definitions
- an accompanying **mobile app**
@@ -65,7 +65,7 @@ mount, whether its sidecar is in the ecosystem file, and so on.
db schema · backend router + routes · frontend router + routes · sidecar.
**Not** a context menu, **not** officer-items capabilities, and (probably) **not** a settings page.
**Not** a context menu, **not** officer-items permissions, and (probably) **not** a settings page.
---
@@ -202,7 +202,7 @@ before a plugin install can interrupt somebody's terminal.
The two tables remain two lists, which is the same seam as the totality bug below.
### What this means for `assertCapabilityTotality`
### What this means for `assertPermissionTotality`
It can no longer be only a boot check, because the mount set changes after boot. The question moves to
**per rebuild**: `buildApp()` is the one place routes are mounted, so it is the one place to assert that
@@ -222,9 +222,9 @@ Two things it must survive, both live today:
## Permissions
A plugin declares capabilities. **A plugin may declare `app`, and nothing else.**
A plugin declares permissions. **A plugin may declare `app`, and nothing else.**
`CapabilityKind` is `core | app | confined | execution | admin`. `core` means _every account, not
`PermissionKind` is `core | app | confined | execution | admin`. `core` means _every account, not
deniable_, so a third-party manifest naming its own kind is a privilege-escalation surface: "malicious
plugin declares itself core" is an ungated grant to every user. `core`, `execution` and `admin` stay the
platform's to assign.
@@ -232,7 +232,7 @@ platform's to assign.
### The platform grants read or write. Everything richer is the plugin's own job
The platform's contract is exactly what it already has and no more: **a role holds `read` or `write` on a
capability**, stored in `role_capabilities`, enforced by the gate. `read` permits safe methods anywhere in
permission**, stored in `role_permissions`, enforced by the gate. `read` permits safe methods anywhere in
the surface; `write` permits everything.
Anything beyond that — who may see whose rows, per-user isolation, ownership of individual records,
@@ -277,13 +277,14 @@ where offscale's is just "the owner's".
Not designed yet, and deliberately not designed here. Recorded so the intent survives.
### Three different things are called "capability" here
### Several different things were called "capability" here
A manifest needs three names, not one:
1. `capabilities/registry.ts`**permissions** (`headscale`, `vpn`)
1. `permissions/registry.ts`**permissions** (`headscale`, `vpn`)
2. `$OFFICER_ROOT/capabilities/` — the **file-based item store** (skills, tools, tasks)
3. `sidecar-registry` `capabilities: ['music']`**routing keys** for `sendCommand`
3. `sidecar-registry` `handles: ['music']`**routing keys** for `sendCommand`, renamed from
`capabilities` on 2026-08-15
Offscale needs (1) and (3), and not (2).
@@ -321,7 +322,7 @@ Officer had two headscale surfaces:
| | `/api/vpn` | `/api/headscale` |
| ---------- | ---------------------------------------- | -------------------------------------- |
| capability | `vpn`, kind `app` — grantable to members | `headscale`, kind `admin` — owner only |
| permission | `vpn`, kind `app` — grantable to members | `headscale`, kind `admin` — owner only |
| purpose | enrol your own device | the tailnet: machines, routes, ACLs |
| surface | one route, `POST /enroll` | the whole admin API |
@@ -387,7 +388,7 @@ is correct. The invite flow supersedes it completely:
4. the device joins
That path needs no per-member permission on Officer at all, and it is the one that exists and works.
`/api/vpn/enroll` was the design it replaced, not a capability still waiting for a UI — there never was
`/api/vpn/enroll` was the design it replaced, not a permission still waiting for a UI — there never was
one. Do not reintroduce a member-facing enrolment route on the assumption something is missing.
---
@@ -438,7 +439,7 @@ The whole frontend↔host coupling, which becomes the plugin API:
| `helpers/clipboard``copyToClipboard` | carries the non-secure-context fallback; re-implementing it would silently regress |
| `AppRegistryMeta` | the panel-contribution contract |
| `officerdev``WorkspaceView`, `LayoutNode` | needs `appTypes: {allowed, fallback}` and `locked` |
| `state/useDashboardState` | per-user layout, backed by `/api/dashboards`, a `core` capability — stays host-provided |
| `state/useDashboardState` | per-user layout, backed by `/api/dashboards`, a `core` permission — stays host-provided |
| `../Terminal/Terminal``TerminalView` | **the awkward one** — a code dependency on another panel app |
### `assist.ts` travels, but stays unwired
@@ -470,7 +471,7 @@ export const manifest = {
icon: 'Network',
color: '#818cf8',
// Named `permissions`, NOT `capabilities`. That word already means three different things here — the
// Named `permissions`, NOT `capabilities`. That word meant several different things here — the
// permission registry, the officer-items store, and the sidecar's routing keys — and a fourth would be
// one too many. `permissions` is accurate and free: the old table of that name went in 044aacf4.
permissions: [
@@ -568,7 +569,7 @@ means a tile. Fine for offscale; add a flag the first time something needs it.
### `admin` has to be allowed, and the pilot proved it immediately
The earlier rule here was "a plugin may declare `app`, and nothing else". **That is wrong, and offscale is
the counterexample**: its capability is `kind: 'admin'` — owner-only — and it should stay that way.
the counterexample**: its permission is `kind: 'admin'` — owner-only — and it should stay that way.
The distinction is direction. `core` means _every account, undeniable_, so a plugin claiming it grants
itself to everyone: escalation. `admin` means _owner only_, which is a plugin **restricting** itself, and
@@ -616,24 +617,24 @@ Used for both `/api/...` and the frontend route. Nothing else in the codebase ma
server across repeated install / enable / disable / uninstall cycles, checking PM2 and the frontend each
time.
| Piece | Where |
| --------------------------------------- | ---------------------------------------------------- |
| Manifest, `mountPrefix`, validation | `servers/plugins/manifest.ts` |
| Discovery by convention | `servers/plugins/discover.ts` |
| Disk ⋈ database, mounts, dock manifests | `servers/plugins/mount.ts` |
| Install runner, four verbs, streamed | `servers/plugins/install.ts` |
| PM2 ecosystem entry | `servers/plugins/ecosystem.ts` |
| Schema barrel + `db:push` | `servers/plugins/schema.ts` |
| `Plugins.gen.tsx` + `Bun.build` | `servers/plugins/generate.ts` |
| `buildHonoApp` / `rebuildHonoApp` | `servers/hono.ts` |
| Capability registration | `capabilities/registry.ts``setPluginCapabilities` |
| Install state | `plugin_installs` |
| The screen | `/plugins`, two panels, SSE log |
| The reference plugin | `plugins/example/` |
| **The first real plugin** | `plugins/offscale/` — 45 files |
| Piece | Where |
| --------------------------------------- | -------------------------------------------------- |
| Manifest, `mountPrefix`, validation | `servers/plugins/manifest.ts` |
| Discovery by convention | `servers/plugins/discover.ts` |
| Disk ⋈ database, mounts, dock manifests | `servers/plugins/mount.ts` |
| Install runner, four verbs, streamed | `servers/plugins/install.ts` |
| PM2 ecosystem entry | `servers/plugins/ecosystem.ts` |
| Schema barrel + `db:push` | `servers/plugins/schema.ts` |
| `Plugins.gen.tsx` + `Bun.build` | `servers/plugins/generate.ts` |
| `buildHonoApp` / `rebuildHonoApp` | `servers/hono.ts` |
| Permission registration | `permissions/registry.ts``setPluginPermissions` |
| Install state | `plugin_installs` |
| The screen | `/plugins`, two panels, SSE log |
| The reference plugin | `plugins/example/` |
| **The first real plugin** | `plugins/offscale/` — 45 files |
Nothing needs a restart. Routes swap by rebuilding the Hono app, the sidecar gets a PM2 entry, the
frontend is regenerated and rebuilt in ~3s, capabilities are registered before routes mount, and the
frontend is regenerated and rebuilt in ~3s, permissions are registered before routes mount, and the
whole thing survives a restart because boot regenerates and mounts before `serve()`.
### Three bugs the extraction found