Skip to content

docs(plans): multi-member workspaces - #91

Open
pythonlearner1025 wants to merge 1 commit into
mainfrom
plan/multi-member-workspaces
Open

docs(plans): multi-member workspaces#91
pythonlearner1025 wants to merge 1 commit into
mainfrom
plan/multi-member-workspaces

Conversation

@pythonlearner1025

Copy link
Copy Markdown
Member

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.

Content Shared?
The machine and its services shared
Folders and files shared
Git repositories private, per member

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 .git whose 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

  1. Identity — mint the credential for the acting member, not the workspace owner.
  2. Isolation — one Linux user per member; blitz-cred serves by calling user, not by environment variable.
  3. Content — the workspace owns its repository list; seed member copies with git clone, never git worktree.

Build 1 first: it stops the silent misattribution on its own.

Two open questions, both in the plan

  1. A viewer can read another member's tokens, because credentials appear in session output and a viewer watches that output.
  2. Nothing identifies who changed a shared file. Commits identify a coder; plain files have no equivalent yet.

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

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant