docs(plans): multi-member workspaces - #91
Open
pythonlearner1025 wants to merge 1 commit into
Open
Conversation
Several people work in one workspace. Each holds their own credentials and commits under their own account. They share the machine, not the code. Today a box has one Linux user and one credential, so every member acts as the workspace owner: member Y's commits show member X, and nothing reports it. The sharing rule splits by content type rather than by role. The machine, its services, and plain files stay shared. Only git repositories go private, because one shared checkout means one index and one branch, and one `.git` whose hooks run as another member. A team that never touches git shares almost the whole workspace. Plan only. No code changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pythonlearner1025
added a commit
that referenced
this pull request
Aug 29, 2026
…a machine (#106) * docs(plans): member machines — the workspace is a server Supersedes plans/MULTI-MEMBER-BOX.md (PR #91). Every member of a workspace gets one always-on machine, sized by the workspace machine type, provisioned on invite, destroyed on leave. Machines never appear in the product. Includes the session-rail UI overhaul spec and the mockup it is grounded on. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(plans): record the ground-truth survey for member machines Five parallel read-only sweeps over main @ cbf9a1f: compute lifecycle, identity and entitlements, box and sessions, webapp, credentials. File:line grounded; feeds the MEMBER-MACHINES builds. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(plans): member machines v2 — workspace is its own template Remove the template concept; store workspace members with roles; move every VM column to a machines table keyed by (workspace, membership) or invite; auto_provision toggle; workspace-admin vs org-admin split; workspace_credentials added on top of always-available personal credentials; defer auto-upgrade. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(plans): machine types are per machine, never a workspace restriction The workspace holds only a default. Each machine carries its own type, overridable at create and invite, mutable via SetMachineType: destroy the VM, keep the volume, provision the new type on the same volume. Cross-location change deferred until a volume move exists. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(plans): sidecar the strip+rail UI refactor, sessions-free Adopt columns 1 and 2 of the session-rail mockup now, listing the existing managed tab types per workspace. Tab strip, panes, right icon strip, and mobile drawer stay unchanged. Build 2 later swaps the rail's data source to session rows without a layout change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(plans): ground the UI sidecar in the session-rail mockup The mockup is the canonical reference for strip and rail: structure, dimensions, and tokens come from it. Add an element-by-element mapping from mockup selectors to today's data and to the Build 2 swap. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(plans): drop the invite concept from the workspace model Workspace members are added directly from active org members, and the machine allocates immediately on add (auto_provision default on). The machines table keys by membership only; invite_workspaces is gone. Org invites stay an org concern, out of scope. Add a terminology note: machine (durable object) / vm (provider incarnation) / box (guest runtime). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(plans): unify boxes into machines — one row, no stored principal Delete the boxes table. Phone-home enrolls against the machine row and mints machine_token_families stamped with the vm they were minted for. The acting principal derives from machines.membership_id at call time, so the D4 misattribution becomes structurally impossible. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(plans): workspace admin owns workspace credentials; member/type UI Move workspace-credential management from org admin to workspace admin. Restate the role scopes with a per-role summary and a cleaner matrix (org admin passes through implicit reach). Add §6b: MachineTypeSelect and WorkspaceMembersEditor, reusing MachineCatalogGrid, the ShareWorkspaceDialog people-search pattern, and WebAppSelectMenu. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(plans): rename workspace role editor to member; creation stays org-admin Roles are now admin | member | viewer. Add a naming note against the org memberships table. Workspace creation is org-admin only for now. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(plans): explain the two workspace-credential deliveries env rides the existing creds/env.d path, sealed and revocable; header is on-demand via blitz-cred with the recorded header shape, optionally proxy-custodied so the value never reaches the machine. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(plans): unify workspace credentials — one sealed store, two reads workspace_credentials replaces workspaces.environment. A row is an env var name plus a sealed value: exported ambiently through env.d, and served on demand by blitz-cred behind the personal-first rule. The delivery enum and header shape are gone. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(plans): workspace credentials serve through blitz-cred only Delete the env.d ambient delivery: the broker-written file, profile sourcing, tmux -e, chat-turn env merge, and the workspace-environment contract retire. blitz-cred is the single door; agents export scoped env vars themselves when a tool needs one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * broker: delete the workspace-environment fetch and the env.d writer Workspace credentials serve through blitz-cred only (plans/MEMBER-MACHINES.md §1). The ambient env.d delivery path was the only consumer of the workspace-environment route, so the fetch, the decoder, the creds/env.d/00-workspace.sh writer and the profile hook that sourced it all go together. The startup script and filesReady rode the same route and the same workspaces.environment column, so they retire with it: the control-plane route becomes legacy-empty and the column is dropped. blitz-cred list/get/env/git-helper are untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * box: stop sourcing creds/env.d in blitz-term and in chat turns The broker no longer writes creds/env.d/00-workspace.sh, so the two readers go with it: /etc/profile.d/blitz-creds.sh is deleted, and blitz-term drops the glob and the environment delta it fed into `tmux -e`. The locale pair stays on `-e`, because the tmux server snapshot a box boots with has no LANG and that has nothing to do with credentials. Session naming, the read-only attach and recipe injection are untouched. The actor's CredentialSource keeps harness-token delivery and loses the workspace-environment merge: a turn now runs on the actor's own environment, and every credential is pulled at the moment of use with `blitz-cred get`. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * webapp: split CloudApp into rail, panes, routes and dialog stack CloudApp.tsx was 2,265 lines and on the max-lines debt list. Four containers move out, mechanically and with no behaviour change: - src/shell/ShellRail.tsx — column one (wraps DriveRail today) - src/shell/WorkPanes.tsx — the .webapp-panes grid and its surfaces - src/shell/SecondaryRoutes.tsx — every non-workspace route branch - src/shell/ShellDialogs.tsx — the modal stack the rail raises - src/shell/PasteCodeModal.tsx — lifted out of CloudApp's preamble CloudApp.tsx: 2,265 -> 1,877 lines. Gates unchanged: typecheck clean, lint baseline untouched (102 anti-slop, 7 max-lines warnings), webapp 41 files / 336 tests green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * schema: retire the workspace-environment cross-runtime contract The fixture corpus and its conformance tests pinned a payload no runtime consumes any more. The control-plane route file is left intact so it keeps compiling while it is made legacy-empty; only the corpus and the conformance test go here. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * webapp: query the shell by role and label, not by class The rail rebuild would have broken ~20 assertions that reached for presentational classes. They now ask for the same things by accessible name: - `.drive-rail` textContent -> button[aria-label="<workspace>"] - `.webapp-org-button/-menu-*` -> aria-label + role=menuitemradio state - `[data-rail-session-id]` -> [aria-label^="Sessions in "] button, active read from aria-current - `.webapp-pane-strip[data-region]` -> the strip's own tablist label - `.webapp-rail-strip` -> [aria-label="Workspace panels"] - `#webapp-workspace-drawer` -> [aria-label="Workspace drawer"] - `.drive-rail--open` -> the toggle's aria-expanded Three accessible names were missing and are added: the org button names its org, the org menu names itself, and a workspace row names itself. The org-menu backdrop becomes a labelled button, matching the scrims elsewhere in the shell. `runningTwo` gets its own name so two rail rows are not both called "workspace-running-name". webapp 41 files / 336 tests green; typecheck clean; lint baseline untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * box: drop the last creds/env.d references from guest comments Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * webapp: build the strip and the session rail, retire DriveRail Columns one and two are rebuilt against plans/mockups/session-rail.html (plan §6a). DriveRail conflated org header, global nav, workspace list and sessions in one 264px column; it is gone. - `src/shell/WorkspaceStrip.tsx` (48px): the org mark carrying the org-switcher popover, one `.wtile` per workspace (2-3 letter code, name as the accessible name, state in the tooltip, ring on the active one, dashed when the box is not running), a "+" tile for the create dialog, the Files/teenyapps/Connections surface icons, and the account menu on the bottom edge. - `src/shell/WorkspaceSessionRail.tsx` (252px): `.rhead` with the workspace name, an empty `.sub` slot, and share; a pinned "New session" bar; one row per managed tab, drawn gutter · title · time with the time slot empty. A row click activates that tab. - `src/shell/ShellNav.tsx` composes both. Above the breakpoint it is `display: contents`, so each column is a real grid child; below it, the wrapper is the off-canvas drawer the rail used to be, and it carries the rail too whenever a workspace is open. - `src/strip-rail.css` ports the mockup's dimensions, radii and row sizes onto the tokens.css scale; `--live` is the one token it added. - The shell grid becomes 48px | 252px | work | icon strip. Drive, settings, templates and recipes keep the strip and take the width. Deviations from the mockup, deliberate: - The rhead also carries a details button. The mockup has no per-workspace kebab, and §6 has not landed, so this keeps the details dialog reachable. - The surfaces keep the names the right icon strip already uses (Files, teenyapps, Connections) rather than the mockup's "Ports": one panel, one name. - Templates and Recipes leave navigation with the template concept. Drive moves to the account menu, which also carries the Discord link. - The rail's New session offers the tab strip's list from the same SPAWN_SESSION_TYPES source; WebAppHeader is untouched. 654 lines of DriveRail-only CSS leave webapp-shell.css and drive-shell.css. webapp 41 files / 342 tests green; typecheck clean; lint baseline untouched; vite build clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * webapp: delete the environment and startup-script surface The guest runner is gone and the control-plane column drops with it, so the editor would have become a setting that silently does nothing. Deleted: `src/EnvironmentEditor.tsx` and both its mounts (the create dialog's Advanced section and the template screen's), the `environment` field on both GitHub connect drafts and its parser, the `environmentConfigured` / `startupConfigured` derivation in `api-adapter.ts` with the two Configuration rows in the details dialog that read it, and the matching fields on `CloudWorkspaceModel` and `WorkspaceRecord`. Neither the create body nor the template body carries an `environment` any more. 43 lines of now-dead CSS go too. Credential UI is untouched — a later pass owns it. The wire types in `packages/schema` are untouched: another agent owns that side. webapp 41 files / 339 tests green (three environment-editor tests are deleted with the surface); typecheck clean; lint baseline untouched; vite build clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(control-plane): the workspace is a server, every member gets a machine Implements plans/MEMBER-MACHINES.md §1-§4 in the control plane and schema. Migrations 0041-0044 (0040 is reserved by the unmerged trials PR): 0041 workspace_members, machines, machine_token_families; the data migration reuses each existing box id as its machine id, so every deployed guest keeps the identity and the token family it already holds; workspace_grants and org_share_role convert to stored roles. 0042 workspace_credentials, sealed AES-256-GCM under `wscred:<workspaceId>:<name>`; workspaces.environment converts. 0043 drops the four workspace_template tables; recipes re-point at a source workspace and their launch is parked behind a 400. 0044 drops workspace_grants and every VM column the workspace shed. Core: performWorkspaceCreate writes a config row and provisions one machine per member; machine lifecycle (provision/stop/start/recreate/destroy/ SetMachineType); workspace member and credential routes; phone-home and box-config become per machine; the webApp proxy routes to the requesting member's machine; the credential mint resolves its acting principal from machines.membership_id at call time, which retires the stored-principal path; janitors, vm_limit and vmsUsed all count machines. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(control-plane): re-point the module graph, blitzdev schema and test helpers at machines Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(control-plane): re-point the suite's seeding and SQL at machines Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(control-plane): machines in graceful destroy, box config, identity backfill Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(control-plane): pin the legacy environment shim and the member-role sharing model Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(control-plane): rewrite the template suite around workspace clones Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(control-plane): recipes launch from a workspace and its launch is parked Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(control-plane): unmount the template and recipe surfaces Templates and Recipes are disabled product-wide (2026-08-29). Both registrations stay in core/app.ts, commented, with the reason; recipe code and rows are untouched and only the routes are gone. Org usage capture is split out of core/recipes.ts because it is not a recipe surface. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(control-plane): member routes replace grants; the viewer reaches the owner's guest read-only Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: record the member-machines contracts and the machine-based vmsUsed Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(control-plane): pin the managed module manifest for the new core modules Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(control-plane): keep providerOperationError honest about its unknown input The parameter really is unparsed provider output, so it stays `unknown` and the anti-slop baseline stays where it was rather than being widened by a type that only looks narrower. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * wire: the member-machines fields are required, not optional mm-core left `orgId`, `ownerMembershipId`, `defaultMachineTypeId`, `autoProvision`, `myRole`, `members` and `credentials` optional because the webapp fixtures lived on another branch. They are in the same tree now, and the only client of this view is that webapp, so optionality bought nothing and cost every reader an absence to handle that cannot happen. The webapp test fixtures move onto one `workspaceViewFixture` builder rather than restate seven defaults in five files. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * webapp: build the member, machine and credential surfaces against the wire The control plane serves members, machines and workspace credentials, so the webapp stops calling the retired `workspace_grants` routes and administers the real thing. New per plan §6b: - `MachineTypeSelect` — the compact type picker on `WebAppSelectMenu`, sharing `groupMachineTypes` and `monthlyPriceLabel` with the grid. On a live machine it disables the types outside the volume's location and says which location that is, because a missing option reads as a bug. - `WorkspaceMembersEditor` — one row, two modes. Draft feeds `CreateWorkspaceRequest.members[]`; live calls the API per edit and carries the machine state chip and the lifecycle menu. The details dialog becomes Members / Credentials / Settings. Compute and Storage collapse into the member rows, because a workspace has no single machine to describe. `SetMachineType` confirms that it keeps the disk before it writes. Settings is read-only over the fields no route can write yet, and says so rather than offering controls that do nothing. `ShareWorkspaceDialog` retires with `workspace_grants`: membership is sharing now, so the rail's people icon opens the Members tab. `ownerMembershipId` comes off the wire instead of being inferred from "the viewer is the owner", which could only ever name the viewer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * webapp: disable the template and recipe routes; cover the new create sections The control-plane registrations are already unmounted, so these addresses opened pages whose every request 404s. The route branches are commented out in sessions-page-state.ts with the reason, /templates* and /recipes* fall through to Drive, and SecondaryRoutes stops rendering them. The page components, the client methods and the recipe rows stay in the tree, unreachable. The create dialog loses the Templates section with them — a workspace is its own template now, and "new workspace from existing" is the clone. New coverage: the default-machine-type label, a per-member type override, a viewer row without one, credential rows, the org-admin refusal, and the clone header. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * webapp: drop the org-share read path with its route `PUT /workspaces/:id/org-role` is gone from the control plane, and the `orgShareRole` it wrote is projected as a constant null: sharing is a stored workspace role now. The client method could only 404, and the model field could only read null while looking like a sharing signal. `CreateWorkspaceRequest.orgShareRole` stays: at create it is still a real instruction, adding every active org member at the matching workspace role. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * webapp: offer a machine action only where the wire gives an id to act on The lifecycle menu listed Provision on a member with no machine, but the projection sends `machine: null` for a destroyed or absent one and every lifecycle route is keyed by machine id — so that entry could only ever report an error. A member gets a machine through the role write, which provisions one where the workspace auto-provisions. Provision now appears on an error row alone: the one reachable state whose VM may be missing. A machine that is provisioning or destroying accepts nothing until it arrives. The state chip renders on a machineless row too, so the absence is stated rather than left blank. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * workspaces: make the settings a workspace admin owns writable The details dialog rendered name, default machine type, auto-provision, agent rules and repos, then explained that no route existed to change any of them. The route exists now. `PATCH /workspaces/:id` takes a partial body and leaves every absent field alone, so the four controls do not have to restate each other. It is workspace-admin work per the §3 matrix, and an org admin passes through implicit reach — the same `workspace-access.ts` gate the member routes use, which moves here as `workspaceForAdminWrite` rather than being copied. `defaultMachineTypeId` is validated against the machine-type registry at write time, so a workspace cannot hold a type no provider claims and discover it at every provision instead. It is a default: it moves what a FUTURE machine takes and touches no existing one. The repository list was fixed at create (plans/WORKSPACE-REPOS.md, "add and remove come later"). Three routes serve it now: a read any member of the workspace may make, and an admin-gated add and remove. The add derives `private` with the caller's own credential and refuses a private repo without an App grant, exactly as create does — a clone that cannot authenticate takes 600 seconds to say so, so the refusal belongs at save time. A change reaches a machine at its next provision. Routes live in a new `core/workspace-settings.ts` rather than in `core/workspaces.ts`, which is over the 700-line warn. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * workspaces: give a machineless member a way to get a machine A member row could exist with no machine — the workspace has `auto_provision` off, or theirs was destroyed — and nothing in the product created one. Every lifecycle route is keyed by a machine id, and that row has none, so the Members tab offered the member no verb at all. `POST /workspaces/:id/members/:membershipId/machine` is keyed by the membership instead. Workspace-admin gated per §3, with org admins passing through implicit reach. A viewer is refused: they never hold a machine (§2.2), and the role write is the way to give them one. A member who already has a live machine is refused too — this route creates, and `start` and `recreate` are the verbs for one that exists. An existing destroyed row is reused, so the member comes back on their own disk. The optional `machineTypeId` overrides the workspace default for this one machine, and an unclaimed type is refused before anything is created. `machineActionsFor(null)` now answers `['provision']` rather than an empty list, so the row offers the one verb that applies to it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Delete the retired create-workspace surfaces and their dead chains Five deletions that leave no consumer behind, plus the two query narrowings and the recreate fix that fell out of reading them. - `orgShareRole` goes end to end: the create dialog's Sharing section, the parse branch, the roster expansion, and the field on both `CreateWorkspaceRequest` and `WorkspaceView`. The members editor is the only membership control at create, so the per-member quota swallow goes with the roster it existed for — a create that spends the org's last VM slot now answers the 409 instead of handing back a short roster. - `CreateWorkspaceRequest.environment` goes with its parse, its convert-to-credentials loop, and the now-unreferenced parser and ceilings in `core/environment.ts`. The constant three-field shim route stays untouched: deployed brokers still poll it every second at boot. - `WorkspaceView.orgShareRole` and `.environment` were constant null. The phase and ssh projections are NOT these and are left alone. - `GET /workspaces/:id/credentials` had no consumer: the names already reach the webapp on the workspace view. The route, the client method, the response type and the five test doubles go together. - Dead imports in `connections/mint.ts` and the unused `MachineView` in `wire.ts`. The rest of that import block binds names the declarations below use by hand, so it stays. Two narrowings and one fix found while reading the above: - Names-only credential queries stopped selecting `ciphertext`. `WorkspaceCredentialRow` is now the columns the view reads, and the one caller that wants a value selects `ciphertext` alone. - Recreate no longer falls back to `machines[0]`. With auto_provision off that restored the workspace on ANOTHER member's volume; the owner's machine or the existing 409. - Migration 0042 drops two json_* clauses the JOIN already implies, keeps the GLOB, and says plainly that underscore-prefixed names are discarded. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Project a workspace record through one helper, so agentRuleId survives a poll `createWorkspaceModel`, `workspaces_loaded` and `workspace_records_refreshed` each listed the record's fields by hand. The two merge paths had drifted: neither carried `agentRuleId`, so the Settings tab compared the value it loaded once against a rule the user had since changed, and read stale. `applyRecord(existing, record)` is the single projection all three now use. What it takes from `existing` is only what the server does not own — the local title, the chosen agent, and the last value of the three fields a record may omit. Everything else comes from the record, so the next field added to `WorkspaceRecord` reaches all three paths at once. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: rewrite the README for member machines Templates leave the feature list (a workspace is its own template), recipes move to the roadmap, and the sharing story becomes: add a teammate, their machine is running before their first click. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Plan only. No code changes — one new file,
plans/MULTI-MEMBER-BOX.md.The problem
A box has one Linux user and one credential, so every member acts as the workspace owner. Member Y connects GitHub inside member X's workspace, the box still mints X's token, and Y's commits show X. Nothing reports the error.
The shape
Each member gets their own identity inside the box: their own Linux user, their own credential, and their own copy of the code. They share the machine, not the code.
The rule worth arguing about
Sharing splits by content type, not by role.
Git is the only exception, and for a stated reason: one shared checkout means one index and one branch, so agents block each other, and one
.gitwhose hooks run as another member. Git already copies code between people, so a shared checkout adds nothing.Everything else stays shared. A team that never touches git shares almost the whole workspace.
Three builds
blitz-credserves by calling user, not by environment variable.git clone, nevergit worktree.Build 1 first: it stops the silent misattribution on its own.
Two open questions, both in the plan
Accepted trade-off
The box states which member a session belongs to and the control plane believes it. A compromised box therefore reaches every member of that workspace, not one. Recorded in the appendix as an owner ruling.
🤖 Generated with Claude Code