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
+29 -29
View File
@@ -165,7 +165,7 @@ and the rename sequence leaves `workspaces` with no zombie.
sequence after fixing.
**Resolved, and verified as the specific sequence:** create `zombie-test`, then PATCH
`{ws-layout-renamed: …, ws-layout-zombie-test: null, ws-terminals-zombie-test: null,
ws-host-terminals-zombie-test: null}` in one body. `workspaces` comes back as
ws-host-terminals-zombie-test: null}` in one body. `workspaces` comes back as
`['agent-mvp', 'renamed']`.
---
@@ -174,7 +174,7 @@ and the rename sequence leaves `workspaces` with no zombie.
`dashboards` is **empty (0 rows)** today, so none of this has fired yet. Members can now sign in
(`d8ee678`), so it is a matter of time. Note `TODO.md`'s preamble still says the platform is collapsing
to single-user — that predates the capability permission model and should not be used to deprioritise
to single-user — that predates the permission permission model and should not be used to deprioritise
these.
> **Re-measured 2026-08-07.** The premise above has moved and the section is no longer hypothetical.
@@ -184,7 +184,7 @@ these.
>
> It also puts this section in **direct contradiction with `CLAUDE.md`**, which opens by calling
> single-user "a hard invariant, not a stage" — no roles, no per-user isolation, and "if a change seems
> to need *which user is this*, the answer is always the owner." Five rows in `users` says otherwise.
> to need _which user is this_, the answer is always the owner." Five rows in `users` says otherwise.
> One of the two documents is wrong about what this platform is, and that is a **product question, not a
> defect**: the answer decides whether the item below is urgent or should be deleted along with the rest
> of the section.
@@ -371,7 +371,7 @@ playback, transcodes — not as a prerequisite for agent coordination._
**Measured `56ca411`.** `WorkspaceRenderer.test.tsx` mounts the real renderer against a mount-counting
probe app and lets the real `layout-utils` mutators produce the "after" tree. The table below was written
from reading the code; the test disagrees with its *diagnosis* in every row, and found one row it had
from reading the code; the test disagrees with its _diagnosis_ in every row, and found one row it had
missed entirely. Read this paragraph before acting on the bullets underneath it.
- **The key is not the cause.** A panel's React identity is its position plus `key={child.node.id}` on its
@@ -391,7 +391,7 @@ missed entirely. Read this paragraph before acting on the bullets underneath it.
host that does not move when the tree reshapes, the way maximize is a CSS toggle on the same element.
That is a redesign, not a patch, and it is still Tier C.
The original table, kept because its *observations* hold even where its explanation did not:
The original table, kept because its _observations_ hold even where its explanation did not:
| operation | remounts? | why |
| ------------------------------------- | ---------------------- | ------------------------------------------------------------------------- |
@@ -410,8 +410,7 @@ The original table, kept because its *observations* hold even where its explanat
Kills rows 2 and 3.~~ **Withdrawn `56ca411`** — measured, and it kills neither. The element type at
that position changes too, which React acts on first. It would also collide a panel id with a group
id, and a panel id is an agent's address now.
- [ ] ~~**Don't re-key the survivor when a group collapses** (`layout-utils.ts:68-70, 83-88`). Kills row
4.~~ **Withdrawn `56ca411`**, same reason: the survivor changes type as well as key.
- [ ] ~~**Don't re-key the survivor when a group collapses** (`layout-utils.ts:68-70, 83-88`). Kills row 4.~~ **Withdrawn `56ca411`**, same reason: the survivor changes type as well as key.
- [ ] **Overlay the mobile ephemeral panel instead of replacing the workspace**
(`WorkspaceView.tsx:165`). Affects `/files`, `/email`, `/chat`, `/browser`, `/dashboards`.
- [ ] **Reference for how it should feel:** maximize (`PanelSlot.tsx:430-457`) is a CSS state toggle on
@@ -463,6 +462,7 @@ The original table, kept because its *observations* hold even where its explanat
that passes and reaches `PanelSlot.tsx:311-317`, which on a `locked` screen renders an empty
teal-bordered box with no picker and no way for the user to recover. - `screens/QrTransferScreen.tsx:19-39` has the guard but no persist-back, so it re-normalises on
every mount forever and never heals the row.
- [x] **~~Then collapse the three default-layout mechanisms~~ — inventoried and dropped.** Per-screen
`defaultLayout.ts` (21, not 20 — `Home/defaultLayout.tsx` is misnamed), `createDefaultLayout()`
in the core, and the 6-entry template array at `DashboardPreview.tsx:33-142`.
@@ -520,7 +520,7 @@ work disagree permanently about the roster, with neither told — a direct contr
the repo. Meanwhile every PATCH computed and returned a full fresh state blob which the client
**discarded** — 3 SELECTs per splitter release, thrown away.
**Resolved `81ad3ef`** — both halves. The PATCH returns `{ok: true}`; nothing had ever read that
body, and a caller that did would be reading state assembled *before* whatever concurrent write it
body, and a caller that did would be reading state assembled _before_ whatever concurrent write it
raced. The client refetches **on focus**, with three non-default guards, because this cache is
optimistic: a refetch that started before an in-flight PATCH landed would overwrite the value
already on screen — the same lost-update shape as the two items above, and self-healing only until
@@ -529,7 +529,7 @@ work disagree permanently about the roster, with neither told — a direct contr
and `refetchOnWindowFocus` gated on a module-level in-flight count plus a 2 s quiet period.
- [x] **Preserve sibling sizes on split.** `splitInner`/`insertPanel` redistribute evenly
(`100 / newChildren.length`), so one split discards carefully tuned proportions.
**Resolved `abea7a3`** — the new sibling takes half of the *target's* size and nothing else moves.
**Resolved `abea7a3`** — the new sibling takes half of the _target's_ size and nothing else moves.
One helper serves both call sites, because the drop path (`movePanel` → `insertPanel`) carried the
identical bug. Two of the three tests were already in `layout-utils.test.ts` asserting the even
split, written to the old behaviour deliberately; they now assert the new one. The third documents
@@ -539,11 +539,11 @@ work disagree permanently about the roster, with neither told — a direct contr
`let tplCounter = 0; const tplUid = () => \`tpl-${++tplCounter}\`` — module-level, no entropy,
resets every page load. Two dashboards created from templates after a reload hold panels with
**identical ids**, and panel id keys `terminal-conn-${panelId}`and`file-viewer:${panelId}`.
**Resolved `6fd60e5`** — the templates now call the core's `uid()`, which is exported from the
Workspace barrel for the first time so that there is exactly one way to mint a panel id. The
duplicate minter is deleted rather than fixed: a second implementation of "make me an id" is how
this happened, and the collision was no longer only a settings mix-up — `agent_panels` addresses a
panel by `(dashboardId, panelId)`, so two dashboards built from templates in the same page load
**Resolved `6fd60e5`** — the templates now call the core's `uid()`, which is exported from the
Workspace barrel for the first time so that there is exactly one way to mint a panel id. The
duplicate minter is deleted rather than fixed: a second implementation of "make me an id" is how
this happened, and the collision was no longer only a settings mix-up — `agent_panels`addresses a
panel by`(dashboardId, panelId)`, so two dashboards built from templates in the same page load
could hand two different agents the same address.
### 5.6 Registry
@@ -557,7 +557,7 @@ work disagree permanently about the roster, with neither told — a direct contr
(`AppRegistry.test.ts`) plus a `console.error` at runtime — the mistake is caught before it ships
and named if it somehow does. Confirmed: all 44 keys are unique today, and the test says so.
Getting the real list into a test needed one thing beyond exporting it: `test-setup.ts` was not
providing `localStorage`, and `MusicPlayer/useLyricsOpen.ts` reads it at *import* time, so the
providing `localStorage`, and `MusicPlayer/useLyricsOpen.ts` reads it at _import_ time, so the
whole app graph was unimportable from a test. That is now fixed, which unblocks testing anything
else that pulls in a panel app.
- [x] **Seeding depends on undocumented mount ordering.** Three call sites call `useAppRegistry()` with
@@ -581,7 +581,7 @@ work disagree permanently about the roster, with neither told — a direct contr
`availableOnPanel: false`, so it can't be picked. If it ever appeared in a layout it would say
"No file selected" forever.
**Resolved `9fcc9c2`** — traced and confirmed dead, then removed rather than repaired. The file
viewer that users actually see is mounted by `useFileViewerPanels` as an *ephemeral* panel, which
viewer that users actually see is mounted by `useFileViewerPanels` as an _ephemeral_ panel, which
supplies `FileViewerBody`/`FileViewerHeader` itself with a provider reading the path from
`?view=`/`?ephemeral=` — it never touched the registry. No stored layout referenced the key
(checked across `dashboards`, `screens`, `dashboard_defaults`, `user_state`, `user_settings`: zero
@@ -624,7 +624,7 @@ All the same bug: an app guessing "am I being closed?" from an unmount, or payin
`TaskRunnerModal.tsx:1320` renders a Stop button while `phase === 'running'`. And a bare `ws.close()`
is not abandonment: `task-executor.ts:303-309` kills the process tree on socket close, the same
`killTree` the Stop button reaches. What is true is the last clause: there is no re-attach, so an
inline run dies with its modal. That is defensible — inline is the *ephemeral* mode and the job path
inline run dies with its modal. That is defensible — inline is the _ephemeral_ mode and the job path
exists for everything else — so this is left alone deliberately rather than left undone.
- [ ] **`VideoPlayer` kills the transcode on incidental unmount.** `apps/Jellyfin/VideoPlayer.tsx:217-223`
POSTs `stopped`, killing server-side ffmpeg, then renegotiates. Fires on every "yes" row in 5.2 —
@@ -639,7 +639,7 @@ All the same bug: an app guessing "am I being closed?" from an unmount, or payin
- [x] **`PanelSlot` defines a component inside render.** — _resolved `c0fae47`_. `DefaultHeader` is gone: the
header is now an element, not a component type, so there is nothing for React to fail to match.
- [x] **The context value is a fresh literal.** — _resolved `c0fae47`_. `useMemo` over the eighteen members.
Note what it does *not* buy: the value still changes whenever `layout` does, because half the
Note what it does _not_ buy: the value still changes whenever `layout` does, because half the
callbacks close over it. What it stops is the renders that change nothing a panel can see — the
ephemeral pane opening, a mobile panel switch, every frame of a maximize animation.
@@ -736,7 +736,7 @@ All the same bug: an app guessing "am I being closed?" from an unmount, or payin
touch the framework half, so the abstraction holds in one direction; the leak is entirely outbound.
**§5.9 is closed as of 2026-08-07.** The context is 15 fields, and the outbound half is `workspace`,
`cwd`, `root` — all three facts about *where the panel is*, which is the one thing a framework of this
`cwd`, `root` — all three facts about _where the panel is_, which is the one thing a framework of this
shape genuinely owes an app. Nothing left on it is an app's vocabulary: the file-browser pair is
deleted, the chat's system prompt is a prop on the chat, and the key three apps used to parse is a
parsed identity. The two hand-written copies of the inert half are one named constant.
@@ -759,7 +759,7 @@ parsed identity. The two hand-written copies of the inert half are one named con
Email can supply a pre-configured chat by panel id.
**Done in `d3922bd`**, exactly that way: both screens put their own `ChatPanelWrapper` in
`components` under the chat panel's id and pass the prefix as a prop. `PanelSlot` prefers a
`components` entry over the registry for the *body* only, so the panel keeps its registry header —
`components` entry over the registry for the _body_ only, so the panel keeps its registry header —
the screens did not have to reproduce any chrome. `ChatPanelWrapper` is exported from the barrel
for it. The same prop came off `WorkspaceLayout`, where it had no callers at all: every settings
pane and job detail rendering through it had always been passing its chat panels `undefined`.
@@ -793,7 +793,7 @@ parsed identity. The two hand-written copies of the inert half are one named con
workspace, plus the state those interactions run on — are one exported `inertInteraction`, spread
by `WorkspaceLayout` and by the `createContext` default. `root` stays omitted, and that is now a
stated decision rather than an oversight: it is only ever read when `cwd` is scoped, and no caller
of `WorkspaceLayout` passes a `cwd` at all, so there is nothing for it to be the root *of*.
of `WorkspaceLayout` passes a `cwd` at all, so there is nothing for it to be the root _of_.
### 5.10 Channel hygiene — _(found 2026-08-07)_
@@ -953,7 +953,7 @@ they are marked below, because a dead-code list that is itself wrong is the wors
- [x] `screens.terminals` / `screens.hostTerminals` columns — never read (confirmed), **but "never
written" was stale**: `upsertScreen` accepted and inserted them, so all 15 rows hold the `{}` it
wrote. The dead parameters and inserts are gone. **The columns themselves are not dropped** — that
needs `bun db:push`, which diffs the *whole* schema, and this tree currently holds another agent's
needs `bun db:push`, which diffs the _whole_ schema, and this tree currently holds another agent's
uncommitted `schema/agent-panels.ts`. Drop them in a push of their own.
- [x] ~~`SELECTED_DASHBOARD`~~ **`SELECTED_DASHBOARD_KEY`** constant — zero consumers. The parenthetical
claiming `SELECTED_DASHBOARD_KEY` was the live one was **backwards**: `'SELECTED_DASHBOARD'` is the
@@ -1009,17 +1009,17 @@ they are marked below, because a dead-code list that is itself wrong is the wors
tests in `56ca411`. The Workspace directory is 76 tests across three files and green.
**Genuinely still untested: `WorkspaceView` and `PanelSlot`.** But note §5.5's lost updates are no
longer what makes that urgent — every mutation in `WorkspaceView` now goes through `onLayoutChange`
as an *updater*, never as a computed tree, which is the structural fix; a test there would be
as an _updater_, never as a computed tree, which is the structural fix; a test there would be
guarding the fix rather than finding the bug. Checked, not assumed — `bun test src/workspaces/officerdev/src/components/Workspace/`.
- [x] **`useDashboardState`, and the strongest argument this section has for itself.** _(`4f8046d`,
branch `agent-coordination-mvp`)_ — 14 tests over the store every layout and every
`config.agentName` is persisted through. They found a live Tier-A-class defect on the first run,
in code written three days earlier to *stop* silent write loss: `revert` decided whether to roll
in code written three days earlier to _stop_ silent write loss: `revert` decided whether to roll
back by asking "does the cache still hold exactly what I wrote?" **by reference**, and
`setQueryData` runs React Query's structural sharing, which rebuilds the object it stores rather
than keeping the one it was handed. Measured against @tanstack/react-query 5.101.4 — an object
value comes back `!==`, a string comes back `===`. So the guard was false for every *container*
value comes back `!==`, a string comes back `===`. So the guard was false for every _container_
the store exists to hold, and a refused write kept its optimistic value in the cache while the
toast said it had been rolled back; the change then vanished at the next reload. Only primitives
ever reverted, which is exactly why nobody saw it. Replaced with a per-key write sequence, which
@@ -1029,7 +1029,7 @@ they are marked below, because a dead-code list that is itself wrong is the wors
been read carefully twice — which is the case for §9 stated better than any argument. And
`mock.module` is **process-wide and permanent** in Bun: a stub that does not spread the real
module deletes exports out from under files that never heard of it. Likewise
`@testing-library/react` auto-registers `afterEach(cleanup)` at *import* time, so it lands in
`@testing-library/react` auto-registers `afterEach(cleanup)` at _import_ time, so it lands in
whichever test file imports the library first and every later file silently gets none — that is
now registered in `test-setup.ts`, where preload's lack of a file scope makes it global. Adding
one test file broke fourteen assertions in `DataTable.test.tsx` before both were understood.
@@ -1037,10 +1037,10 @@ they are marked below, because a dead-code list that is itself wrong is the wors
- [x] **`WorkspaceView`, and the second consecutive bug a test found that review had not.** _(`bfa9967`,
branch `agent-coordination-mvp`)_ — 11 tests over the last untested mutator, driving the real
`WorkspaceView` through the real `WorkspaceRenderer` and `PanelSlot`, so the buttons under test
are the buttons. Two properties: every layout write is an *updater* rather than a computed tree
are the buttons. Two properties: every layout write is an _updater_ rather than a computed tree
(two of the paths are deferred — the 500 ms resize debounce, and a window resize firing `onLayout`
on every group at once — so a computed tree silently undoes the write before it and resurrects an
older `config`); and `usePanelClose` fires on close *intent* only, never on the unmounts a drag,
older `config`); and `usePanelClose` fires on close _intent_ only, never on the unmounts a drag,
a swap or a mobile switch cause.
Four of the eleven failed on the first run, all on one defect. `TrafficLights` took `onRemove`
**and** `isLastPanel` and used `isLastPanel` only to pick the tooltip: the red button read "Close
@@ -1103,7 +1103,7 @@ they are marked below, because a dead-code list that is itself wrong is the wors
branch `agent-coordination-mvp`)_ — the first two items of §5.1, and the terminal orphan leak with
them. `usePanelClose(panelId, handler)`, fired by `WorkspaceView` from `handleRemove` and from
`handleSetApp` when the app actually changes, and from nowhere else.
The interesting part is what it is *not*. This item used to propose diffing the layout before and
The interesting part is what it is _not_. This item used to propose diffing the layout before and
after; two tests now stand in `layout-utils.test.ts` to stop anyone trying it, because `movePanel`
mints a fresh panel id on the way and `swapPanels` exchanges contents between stationary ones — so
a drag reads as a close and a swap reads as two. A panel id is a position in the tree, not an app