Skip to content

feat: member machines — the workspace is a server, every member gets a machine - #106

Merged
pythonlearner1025 merged 46 commits into
mainfrom
feat/member-machines
Aug 29, 2026
Merged

feat: member machines — the workspace is a server, every member gets a machine#106
pythonlearner1025 merged 46 commits into
mainfrom
feat/member-machines

Conversation

@pythonlearner1025

@pythonlearner1025 pythonlearner1025 commented Aug 29, 2026

Copy link
Copy Markdown
Member

Implements plans/MEMBER-MACHINES.md (the plan and its ground-truth survey ride in this PR). Built by parallel agents on three branches, integrated and gap-closed; all gates green at every merge point.

The model

A workspace is configuration; a machines row is the VM. One machine per (workspace, member), provisioned when a workspace admin adds an org member, destroyed when they leave. Stored roles admin | member | viewer in workspace_members. Machine types are per machine — the workspace holds only a default — and SetMachineType swaps the VM under the volume. Workspace credentials are one sealed store served through blitz-cred only. The acting principal resolves at call time from machines.membership_id; the stored-principal misattribution is structurally gone.

Migrations (0041–0044)

  • 0041: workspace_members, machines, machine_token_families. Data-preserving: machines reuse box ids, token families copy hash-for-hash, broker_keys re-keys 1:1 — no deployed guest re-enrolls. Grants and org_share_role convert to stored roles.
  • 0042: workspace_credentials (sealed, AAD-bound); existing env vars migrate under a plaintext:v0: envelope that any write re-seals.
  • 0043: template tables drop; recipes.template_id → nullable source_workspace_id.
  • 0044: the workspace sheds every VM column, workspace_grants, org_share_role, environment.

Compatibility (load-bearing, no expiry)

GET /boxes/:id/feed serves from machines; GET /workspaces/:id/environment is a constant {env:{}, startupScript:null, filesReady:true} because deployed brokers poll it at boot; phone-home legacy adapter untouched.

Removals, called out

  • env.d ambient delivery deleted (broker writer, profile sourcing, tmux -e, chat-turn merge, workspace-environment contract + fixtures). blitz-cred is the single door.
  • Startup scripts no longer run — the guest runner died with the environment fetch; the editor is gone.
  • Templates removed, Recipes disabled: both route registrations commented out in core/app.ts; recipe code and rows untouched; webapp routes for both are unreachable.
  • ShareWorkspaceDialog and PUT /workspaces/:id/org-role retired in favor of the Members tab.

UI

Three-column shell grounded in plans/mockups/session-rail.html: workspace strip, session rail (lists today's managed tabs; sessions-as-objects come later), untouched tab strip and panes. CloudApp split 2,265→1,879 lines. Details dialog is now Members / Credentials / Settings with full machine lifecycle per the §3 matrix; MachineTypeSelect + WorkspaceMembersEditor in create and edit.

Known follow-ups

Recipe launch re-point (TODO(member-machines) in core/recipes.ts), rail .sub slot feed, CloudApp split continuation, sessions-as-objects (plan Build 2).

Gates

typecheck 0 errors; lint at baseline (102/0, 7 max-lines, never raised); CP 633 passed, actor 119, webapp 344, node:test all pass; go vet+go test clean on broker and gateway.

🤖 Generated with Claude Code


Appendix — the plan (plans/MEMBER-MACHINES.md, as merged)

MEMBER MACHINES — the workspace is a server, and every member gets a machine

Written 2026-08-28, revised same day after the ground-truth survey
(plans/evidence/member-machines-ground-truth.md, main @ cbf9a1f).
Supersedes plans/MULTI-MEMBER-BOX.md and the workspace-templates concept.
UI mockup: plans/mockups/session-rail.html.

org
 └─ workspace "engineering"            ← its own template; nothing else is
     ├─ config: machine type · agent rules · repos · credentials (env)
     ├─ workspace_members: (membership, role)      role: admin | member | viewer
     ├─ machines: one VM per member, on by default
     └─ sessions (Build 2)

0. The idea

A workspace works like a Discord server. A workspace admin adds org members
to it with a role — only existing org members; the org roster grows through
the org's own invite system, which this plan does not touch. Each member
gets one always-on machine, provisioned the moment they are added, destroyed
when they leave.
Sessions inherit the workspace's agent rules, drive, and credentials.
Machines never appear as a user decision — only in workspace administration.

The workspace is its own template. There is no separate template object.
A workspace carries the config a template used to carry — machine type,
agent rules, repo list, workspace credentials — and "new
workspace from existing" clones that config. The workspace_templates,
workspace_template_folders, workspace_template_repos, and
workspace_template_connections tables are deleted; recipes re-point their
launch source at a workspace.

1. The data model

workspaces — the durable, configurable thing

The workspace row loses every VM column (they move to machines) and gains
the config role the template used to play. A workspace has no phase: it is
always present; only machines have lifecycle.

workspaces (
  id                   TEXT PRIMARY KEY,
  org_id               TEXT NOT NULL REFERENCES orgs(id),
  name                 TEXT NOT NULL,
  owner_membership_id  TEXT NOT NULL REFERENCES memberships(id), -- creator; first admin
  default_machine_type_id TEXT NOT NULL,  -- a default, never a restriction (§1a)
  auto_provision       INTEGER NOT NULL DEFAULT 1,  -- provision + start machine on member add
  agent_rule_id        TEXT REFERENCES agent_rules(id),
  created_at           INTEGER NOT NULL,
  updated_at           INTEGER NOT NULL
)
-- dropped from workspaces: phase, vm_id, volume_id, ssh_*, phone_home_*,
-- tunnel_id/tunnel_hostname/dns_record_id, compute_credential_source,
-- box_update_*, org_share_role (replaced by workspace_members),
-- environment (replaced by workspace_credentials)

workspace_members — membership with a stored role

Replaces the computed role and the workspace_grants sharing ACL. Roles are
stored, not derived. admin here is workspace admin — distinct from org
admin, with distinct powers (§3).

workspace_members (
  workspace_id            TEXT NOT NULL REFERENCES workspaces(id),
  membership_id           TEXT NOT NULL REFERENCES memberships(id),
  role                    TEXT NOT NULL CHECK (role IN ('admin','member','viewer')),
  added_by_membership_id  TEXT REFERENCES memberships(id),
  added_at                INTEGER NOT NULL,
  PRIMARY KEY (workspace_id, membership_id)
)

Org admins keep implicit reach into every workspace of the org (today's
invariant, workspace-access.ts:39), but implicit reach is access, not
workspace-admin powers — see the matrix in §3.

machines — one VM per (workspace, member)

The machines table takes every VM column the workspace row loses. A machine
belongs to a workspace and to a membership — always. Workspace members are
org members, so the membership row exists before the machine does.

Terminology. A machine is the durable per-member object this table
defines (the word already exists user-facing as "machine type"). A vm
is the provider-level incarnation (vm_id, createVm, VmProvider,
vm_limit). A box stays a word for the guest runtime — the packages,
the image, the gateway — but the boxes table is deleted: the machine
row is the guest's identity. There is one row, not two.

Enrollment folds into the machine. Today phone-home inserts a boxes
row plus a token family, and boxes.principal_id stores the workspace
owner — the stored principal that causes the D4 misattribution. Unified:

  • Phone-home verifies machines.phone_home_hash, mints a token family in
    machine_token_families (the renamed box_token_families, keyed by
    machine_id and stamped with the vm_id it was minted for), and flips
    the machine to running. The capability re-arms at every vm provision.
  • The guest calls the control plane as its machine. boxCaller resolves
    the acting principal from machines.membership_id at call time — no
    stored principal exists to go stale. D4 stops being a fix and becomes
    structure.
  • credential_leases.box_id becomes machine_id; GET /boxes/:id/feed
    becomes GET /machines/:id/feed.
  • A vm destroy (stop, SetMachineType, recreate) revokes the machine's
    token families; the stamped vm_id fences any stale guest that
    outlives its incarnation. The next boot enrolls fresh against the same
    machine row.

(broker_boxes is the broker's own fleet table and is unrelated; it
stays.)

The volume is the durable machine; the VM is an incarnation. Machine
state is volume-backed (#88), so the VM fields are replaceable while the
machine row and its volume persist. This is what makes machine types
per-member and mutable (§1a): a type change destroys the VM, keeps the
volume, and creates a new VM of the new type.

machines (
  id                         TEXT PRIMARY KEY,
  workspace_id               TEXT NOT NULL REFERENCES workspaces(id),
  membership_id              TEXT NOT NULL REFERENCES memberships(id),
  state                      TEXT NOT NULL CHECK (state IN
                               ('provisioning','running','stopped','error',
                                'destroying','destroyed')),
  machine_type_id            TEXT NOT NULL,   -- per machine; defaulted, overridable, mutable
  compute_credential_source  TEXT NOT NULL CHECK (compute_credential_source IN ('org','deployment')),
  vm_id                      TEXT,
  volume_id                  TEXT,
  ssh_host TEXT, ssh_port INTEGER, ssh_user TEXT, ssh_host_public_key TEXT,
  phone_home_hash TEXT, phone_home_used INTEGER,
  tunnel_id TEXT, tunnel_hostname TEXT, dns_record_id TEXT,
  error                      TEXT,
  created_at                 INTEGER NOT NULL,
  updated_at                 INTEGER NOT NULL,
  UNIQUE (workspace_id, membership_id)
)

The change sites for the old stored-principal path are known
(workspaces.ts:1252-1256, mint.ts:243); both now read the machine row
instead.

§1a Machine types are per machine, not per workspace

The workspace holds only a default. The model must never restrict which
types a workspace can hold. Three consequences:

  1. At provision, a machine takes an explicit type when one is given (per
    member at create or add), else the workspace default.
  2. A machine's type is mutable. SetMachineType destroys the VM, keeps the
    volume, and provisions a new VM of the new type on the same volume. The
    member's disk state survives; running sessions restart.
  3. Different members of one workspace can hold different types at the same
    time.

One provider constraint carries over: a volume attaches within its own
location. A type change keeps the volume when the new type is in the same
location. A cross-location change needs a volume move — deferred (§5).
Automatic resize on pressure is also deferred; SetMachineType is the
manual path until then.

workspace_credentials — the statics the workspace adds

Sealed static keys, scoped to the workspace, managed by the workspace
admin
in workspace settings (and at create time). Values are AES-256-GCM
sealed with the existing CRED_MASTER_KEY, AAD =
wscred:<workspaceId>:<name>. Note:
this deliberately reverses migration 0028's ruling ("ad-hoc secrets are a
workspace file"); the reversal is intended and recorded here.

workspace_credentials (
  id                        TEXT PRIMARY KEY,
  workspace_id              TEXT NOT NULL REFERENCES workspaces(id),
  name                      TEXT NOT NULL,   -- the env var name: STRIPE_API_KEY, ...
  label                     TEXT,
  ciphertext                TEXT NOT NULL,
  created_by_membership_id  TEXT NOT NULL REFERENCES memberships(id),
  created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL,
  revoked_at INTEGER
)
-- one live row per (workspace_id, name): partial unique index WHERE revoked_at IS NULL

One store, one read — this table replaces workspaces.environment.
Today's two half-systems (plaintext workspace env vars; sealed but
org-scoped connections) unify here. A row is a name and a sealed value,
and the only consumer is blitz-cred:

  • blitz-cred get <name> / blitz-cred env <name> serve the value on
    demand, behind the §4 resolution rule (personal grant first).
  • Nothing is exported ambiently. An agent or program that wants an env var
    sets it itself, scoped as the agent rules already teach:
    STRIPE_API_KEY=$(blitz-cred get STRIPE_API_KEY) cmd.

An add is available at once — the next blitz-cred call reads the store
live; no sync, no restart. A revoke refuses the next call. No value ever
sits in a file on the machine.

The env.d delivery path is deleted with this. It existed only for
workspaces.environment: the broker-written creds/env.d/00-workspace.sh,
its profile sourcing, the tmux -e pass in blitz-term, the env merge
into chat turns, and the workspace-environment cross-runtime contract
with its fixtures all retire. blitz-cred becomes the single door to
every secret on the box.

Wire types

type WorkspaceRole = 'admin' | 'member' | 'viewer';
type MachineState  = 'provisioning' | 'running' | 'stopped' | 'error'
                   | 'destroying' | 'destroyed';

interface MachineView {
  id: string;
  state: MachineState;
  machineTypeId: string;         // this machine's type; workspace holds only a default
  volumeId: string | null;       // the durable half; survives SetMachineType
  membershipId: string;
  createdAt: number;
  updatedAt: number;
}

interface SetMachineTypeRequest {
  machineTypeId: string;         // same-location: VM recreates on the same volume
}

interface WorkspaceMemberView {
  membershipId: string;
  name: string;
  role: WorkspaceRole;
  machine: MachineView | null;   // null: not provisioned (auto_provision off, or viewer)
}

interface WorkspaceView {
  id: string;
  orgId: string;
  name: string;
  ownerMembershipId: string;
  defaultMachineTypeId: string;  // a default only; each machine carries its own
  autoProvision: boolean;
  myRole: WorkspaceRole;
  members: WorkspaceMemberView[];
  credentials: { name: string; label: string | null; createdAt: number }[];
                                  // names only; a value never crosses the wire
}

interface CreateWorkspaceRequest {
  name: string;
  defaultMachineTypeId: string;
  autoProvision?: boolean;        // default true
  members?: { membershipId: string; role: WorkspaceRole;
              machineTypeId?: string }[];
                                  // existing org members only, added immediately
                                  // machineTypeId: per-member override of the default
  agentRuleId?: string;
  repos?: string[];
  credentials?: { name: string; label?: string; value: string }[];
                                  // name is the env var name
                                  // create-time only path where a value is sent;
                                  // the creator is the first workspace admin
  cloneFromWorkspaceId?: string;  // copy config (never credential values, never members)
}

2. Lifecycle rules

  1. Member added (workspace admin picks an active org member, at create
    or later): the workspace_members row is written immediately. If
    auto_provision = 1 (the default), a machine row is created in the same
    act and provisions to running. If auto_provision = 0, no machine
    yet; it provisions on first open or by workspace-admin action.
  2. Viewer role: no machine, ever. Viewers watch sessions; they do not
    run them.
  3. Member removed from workspace / leaves org: their machines in that
    scope are destroyed after a grace snapshot; volume retention (existing
    7-day sweep) covers restore.
  4. Workspace deleted: all machines destroy; the workspace row tombstones
    after the last machine is gone.
  5. vm_limit counts machines rows in live states; vmsUsed and the
    entitlements fixtures move to the same definition.
  6. SetMachineType: destroy the VM incarnation, keep the volume,
    provision the new type on the same volume. Sessions restart; disk state
    survives. Refuse a cross-location type until the volume move lands (§5).
  7. Org invites are out of scope. The org roster grows through the
    existing org invite system. A new org member holds no machines until a
    workspace admin adds them to a workspace.

3. Permissions

Three workspace roles, one implicit reach. In one line each:

  • Workspace admin runs the workspace: members, roles, machines,
    settings, workspace credentials. The creator is the first admin.
  • Member works in it: own machine, own sessions, credential use,
    drive write.
  • Viewer watches: workspace-visible sessions and drive read. No
    machine, no sessions of their own, no credential use.
  • Org admin is not a workspace role. Org admins hold implicit
    workspace-admin reach in every workspace of the org (today's invariant),
    plus the org-only concerns: the org roster and invites, billing, the org
    compute credential, and workspace creation.
Action WS admin Member Viewer
Workspace settings (name, default type, auto_provision, rules, repos)
Add / remove workspace members (active org members only)
Manage member roles
Machine lifecycle on any member machine (provision, stop, start, recreate, destroy) own stop/start
SetMachineType on any member machine (keep volume)
Workspace credentials: add, rotate, revoke
Workspace credentials: use in sessions
Run sessions on own machine
Watch workspace-visible sessions
Drive: write / read ✓ / ✓ ✓ / ✓ — / ✓
Delete workspace

Org admins pass every ✓ in the WS-admin column through implicit reach.
Workspace creation is org-admin only for now; a later revision can open it.

Naming note: the workspace role member and the org memberships table
are different things. A person is an org member through memberships, and
holds a workspace role (admin | member | viewer) per workspace.

4. Credentials — two planes, one resolution rule

Personal plane. All of a member's personal credentials are available on
that member's machine, in every workspace: connection grants (GitHub, model
keys) resolve against the machine's member, and harness logins roam via
the broker exactly as today (broker_members is already per-principal).
This replaces today's owner-resolution (mint.ts:243) — the machine's
member IS the identity, so nothing is borrowed and no disclosure banner is
needed.

Workspace plane. The workspace box only adds the workspace
credentials from §1. Every member's machine in the workspace can use them
(viewer excepted — no machine). Audit rows name the machine and its member.

Resolution rule for blitz-cred get <name>:

  1. The member's own grant for <name>, if one exists — personal wins, so
    the agent acts as the member whenever it can.
  2. Else the workspace credential <name>.
  3. Else the existing refusal + connection request flow.

Injection-at-use (values out of transcripts) rides the existing seams
(canUseTool.updatedInput, the git-helper pattern) and lands with Build 4.

5. Deferred

  • Auto-upgrade on pressure (the old D2 resize): deferred. This is a
    later optimization. Until then, SetMachineType is the manual path.
  • Cross-location type change: deferred. It needs a volume move
    (snapshot + restore in the new location). Until then, SetMachineType
    refuses a type whose location differs from the volume's.
  • Per-session credential audit dimension: lands with sessions (Build 2+).

6. The workspace details page (revamp target)

The dialog at /workspaces/:id ("Workspace details", annotated for revamp)
becomes the workspace administration surface. Tabs:

  1. Members — one row per member: name, role selector (WS admin), machine
    state chip, machine type selector (SetMachineType, with a "keeps the
    disk" note), and lifecycle controls (provision / stop / start / recreate
    / destroy) per the matrix. An add-member control at the top — a picker
    over active org members — with a per-member type override.
  2. Credentials — workspace credential list: name, label, created-by,
    created-at; add/rotate/revoke for workspace admins. Values are
    write-only.
  3. Settings — name, machine type (with "applies to new machines" note),
    auto_provision toggle, agent rules, repos, clone action,
    delete.

Today's Compute/Storage panels collapse into the per-member machine rows;
per-machine detail (vCPU, RAM, disk, volume) opens from the row.

6a. Sidecar — the strip and the rail, before sessions

A UI refactor that ships independently of every build. The canonical
reference is the mockup
: plans/mockups/session-rail.html, live at
https://blitzos-session-rail.app.blitz.dev/. Adopt its #strip and
#rail as designed — structure, dimensions, and visual vocabulary come
from the mockup, not from this document. Its stylesheet is the visual
spec (--paper/--ink/--accent tokens, radii, the 48px/252px columns);
port those values into tokens.css rather than restyle by taste.

Scope. Columns 1 and 2 only. No change to the right icon strip
(WorkspaceRailStrip), the tab strip (WebAppHeader), the terminal and
editor panes, or the mobile drawer semantics. No sessions yet — the
rail lists the managed tab types that already exist under a workspace
(claude | codex | terminal | chat from webapp_state), one workspace
at a time. Build 2 later swaps the rail's data source to session rows;
the mockup already draws the end state, so no second layout change comes.

Element-by-element mapping — mockup element → what feeds it now →
what changes at Build 2:

Mockup element Now (pre-sessions) At Build 2
.app grid 48px 252px 1fr replaces drive-shell's 264px rail column; the right icon strip stays as a fourth column unchanged
#strip .orgmark org mark; the org-switcher popover moves onto it unchanged
#strip .wtile per workspace tile with 2–3 letter code, name tooltip, wtile--on ring on the active one .beat live dot when a session is live
#strip "+" tile create workspace (per the §3 matrix) unchanged
#strip surface icons (Files, Ports, Connections) present per the mockup; they focus the same panels the right strip toggles today Keys and Members surfaces join (§6)
#strip .av avatar user/settings menu unchanged
#rail .rhead name + .sub + share icon workspace name; .sub slot stays empty (the mockup shows RAM; machines are not user-facing — open mapping, see below); share opens ShareWorkspaceDialog .sub = live-session count or member count; share becomes the session share popover (Build 3)
.newbar "New session" pinned action; opens the same menu as the tab strip's "+" spawns a real session via the launcher
.s row: .g gutter · .s__t title · .s__a time — "never more" gutter = tab-type glyph; title = tab title; .s__a stays empty (tabs have no clock) gutter = empty for own / face for others; .s__a = time, green when live
.asleep pane not rendered; workspace creating/error states keep their existing main-pane copy not rendered (D3: no sleep state)
launcher pane ("What should we build?") deferred — it creates sessions ships with Build 2

A row click activates that tab in the tab strip; a strip click switches
the workspace. Non-workspace pages (Drive, settings) keep the strip and
use the remaining width. The Templates and Recipes nav rows die with the
template concept (Recipes is already hidden, #103).

Open mapping to confirm: the mockup's .rhead .sub shows the
workspace RAM. Per-member machines have no single RAM figure, and D3
hides machines. The slot stays; the feed is a product call.

Order of work (from the ground-truth survey):

  1. Land or rebase feat/operator-console first — it collides with
    DriveRail, CloudApp, and sessions-page-state.
  2. Split CloudApp.tsx into rail container, work-pane container, route
    switch, and dialog stack. This is the first commit, before visuals.
  3. Migrate the ~20 class-selector test assertions to role/label queries.
  4. Build Strip and Rail as new components against the mockup; delete
    DriveRail; rewire the 8 railFor call sites.

6b. Member and machine-type configuration UI

Per-member machine types (§1a) need UI in two places: workspace create and
workspace edit. The design reuses what exists; two components are new.
Implementation ships with Build 1.

Existing parts to reuse (from the ground-truth survey):

  • MachineCatalogGrid — the radio-card type grid, with groupMachineTypes
    (provider + location groups) and monthlyPriceLabel. Stays the picker
    for the workspace default.
  • ShareWorkspaceDialog — its people search over org members, suggestion
    list, and per-person role selects are exactly the member-picker pattern.
    The pattern is lifted; the dialog itself retires with workspace_grants.
  • WebAppSelectMenu — the listbox popover (outside-click, Escape, focus
    return). Base for the compact type select.
  • DriveAvatar — member avatars in rows.

New component 1 — MachineTypeSelect. A compact select on
WebAppSelectMenu. One option per machine type: name, vCPU/RAM, monthly
price, grouped by provider + location like the grid. First option:
"Workspace default ()". Two contexts gate its option list: at add
time, all locations; on a live machine (SetMachineType), only types in
the volume's location, others visible but disabled with a "volume is in
" note (§1a constraint).

New component 2 — WorkspaceMembersEditor. One list, two modes.
Row = avatar · name · role select (admin | member | viewer) ·
MachineTypeSelect · remove. A viewer row hides the type select (no
machine, §2). Header = the lifted people search over active org members,
plus Add.

  • Draft mode (inside CreateWorkspaceDialog): edits local state only;
    submit sends CreateWorkspaceRequest.members[]. The creator appears
    pinned as the first workspace admin.
  • Live mode (details-page Members tab, §6): each edit calls the API at
    once. Rows gain the machine state chip and the lifecycle menu from §6;
    the type select performs SetMachineType with a "keeps the disk"
    confirmation.

CreateWorkspaceDialog gains a Members section holding the draft-mode
editor, below the machine-type grid (which now labels itself "Default
machine type"). The details page embeds live mode. Nothing else changes.

7. Builds (revised)

Build 1 — workspaces, members, machines. The schema in §1 minus
credentials; migration off the template tables; provision-on-add with the
auto_provision toggle; destroy-on-leave; janitors re-pointed at machines;
vm_limit/vmsUsed re-based; the details-page Members tab. Done when: an
added member's first open lands on a running machine, a removed member's
machine destroys with a grace snapshot, and a WS admin can stop and start
any member machine from the page.

Build 2 — sessions as objects. Control-plane agent_sessions; promote
the ACP actor. The rail shell already exists via the sidecar (§6a); this
build swaps its data source and adds clock, live state, owner, visibility.
Grounding: the survey's Build-2 section.

Build 3 — sharing. Unchanged: visibility enforcement, watch, send with
attribution, fork.

Build 4 — credentials. Member-resolution mint (D4 fix),
workspace_credentials + the Credentials tab, resolution rule §4,
injection-at-use, use-audit events.

Build 5 — pricing alignment. Unchanged, plus the fixtures move for
vmsUsed.

Build 1 and Build 2 stay independent. Build 4's D4 fix can ship inside
Build 1 (it is one resolution change once boxes are member-keyed).

Appendix — standing decisions

  • Sessions default to workspace-visible; private is the exception.
  • Attribution follows the credential; shared context never authorizes a
    personal-credential action.
  • A "send" turn runs as the machine's owner, attributed to the sender,
    Co-authored-by on commits; Fork is the identity escape.
  • Free plan is BYOK twice (compute + model keys); sponsored trials (feat: operator console with sponsored trial invites #104)
    are the only exception. feat: operator console with sponsored trial invites #104 must land before Build 5.
  • Always-on machines; no sleep state in the product. Economics recorded in
    the prior revision and the ground-truth survey.

pythonlearner1025 and others added 30 commits August 28, 2026 19:43
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>
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>
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>
…tion

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>
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>
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>
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>
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>
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>
…rg-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>
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>
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>
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>
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>
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>
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>
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>
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>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
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>
…achine

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>
…test helpers at machines

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ty backfill

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ole sharing model

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s parked

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
… the owner's guest read-only

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pythonlearner1025 and others added 16 commits August 29, 2026 01:07
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… modules

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…known 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>
The guest side retires the workspace-environment contract; the core side
keeps the constant shim the deployed brokers still poll.

CLAUDE.md: the contract row is removed (guest's deletion wins over core's
"(RETIRING)" annotation), guest's retirement note stays, and the Member
machines section gains the line that names the surviving shim and the one
test that pins it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
main's PR #103 hides the Recipes rail entry inside DriveRail.tsx. This
branch deletes DriveRail for the strip and rail, so the deletion wins: the
new strip carries no Recipes entry to hide.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
… 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>
…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>
`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>
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>
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>
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>
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>
…s 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>
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>
@pythonlearner1025
pythonlearner1025 merged commit cb6775f into main Aug 29, 2026
8 checks passed
pythonlearner1025 added a commit that referenced this pull request Aug 29, 2026
* fix(webapp): A2 — one Drive icon in the strip, not three panel toggles

The strip's Files / teenyapps / Connections buttons duplicated the right
icon strip (WorkspaceRailStrip), which owns those panels. The slot now
holds a single Drive icon that navigates to the Drive page — the route the
account menu's Drive entry used.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(webapp): A3 — the rail's "New tab" reuses the tab strip's menu

The rail's pinned action drew its own popover that looked like the tab
strip's "+" menu but listed less: no live ports, no preview links. The
menu now lives in one component, src/NewTabMenu.tsx, and both call sites
render it with their own anchor. SessionTypeIcon moves to
src/SessionTypeIcon.tsx so the shared menu needs no import back into
WebAppHeader; WebAppHeader re-exports both, so its importers are unchanged.
The pinned action reads "New tab". The "+" button behaves as before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(webapp): A4 — Refresh moves under the repositories list, right-aligned

PR #95 put Refresh in the filter row. It now sits in a footer row under the
list, on the right edge, beside the selection count. Behaviour is
unchanged: the same click still bumps the refresh version.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(webapp): A6 — a deterministic gradient per workspace tile

The strip's tiles were flat, so a two-letter code was the only thing
telling them apart. src/shell/workspace-tile.ts hashes the workspace id to
a hue, paints a 135° gradient to hue + 40°, and picks a near-white or
near-black ink from the gradient's average luminance. Ids whose average
lands in the band where neither ink clears 4.5:1 are darkened out of it;
measured worst case over all 360 hues is 4.83:1. The active ring and the
dashed create tile are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(webapp): A7 — the avatar opens settings, and the menu is gone

The strip's avatar drew a four-item popover. Drive is a strip icon since
A2, Settings was one click away, and the name was a label. Clicking the
avatar now navigates to settings. "Ask us on Discord" moves to the bottom
of the settings side navigation, under the section tabs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(webapp): B1 — the details dialog wears the pre-#106 chrome again

The dialog #106 built kept the three tabs the plan asks for and lost the
look the pre-#106 one had. This puts the look back without touching what
any control does:

- the header says `Workspace details “name”` again;
- every panel opens with the micro-caps section heading the old
  `workspace-details-grid` sections wore;
- the member rows take the old access-list geometry (a 28px avatar, a
  48px row) instead of the taller #106 one;
- Clone and Delete leave the bottom of the Settings tab for the restored
  `workspace-details-footer`, which is where the old dialog kept Delete.

Members, Credentials and Settings are the same three tabs, and every
write behind them is untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: a member row can refuse the persistent volume

Every member machine has taken its own volume since #106, and there was
no way to say a machine does not need one. The member row now carries the
answer.

Wire: `members[].persistentVolume` on the create request and the same
optional boolean on `AddWorkspaceMemberRequest` and
`ProvisionMemberMachineRequest`, in `wire.ts` and `packages/schema`
together. Absent means true, so only a refusal travels.

Control plane: the flag reaches `ProvisionMachineInput` and, at the ONE
existing call site in `provisionMachine`, skips
`provisionWorkspaceVolume` exactly as an explicit `volumeId` already
does. No new volume path was written.

Webapp: the toggle sits next to the machine-type select in every member
row, in the create dialog's draft rows and in the live members editor.
A row whose machine exists reports the disk it has, disabled — the disk
is created with the machine and this is not the route that moves one.
The workspace-level Volume section leaves the create dialog; `volumeId`
stays on the wire, where a recreate still reads it.

The test fake grew an optional `volumeLocation`, so a suite can turn on
the volume placement the fake otherwise declines to do.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(webapp): B3 — the select popover takes the top stacking position

Inside the workspace-details dialog the role listbox, the machine-type
listbox and the lifecycle menu all came out under their neighbours. The
popover was `position: absolute`, so `.workspace-details-body` — which
scrolls — was both the thing that clipped it and the thing it could not
paint over.

One rule in the shared stylesheet fixes all three: the popover is
`position: fixed` with the top stacking position, so no scroll container
between it and the page can clip it. `WebAppSelectMenu` writes `left`
and one of `top`/`bottom` from the trigger's viewport rect, clamped to
the viewport, flipping to whichever side has more room; it follows a
scroll in any container above it. The one per-instance override of the
popover's geometry (the mobile settings picker) is gone with it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(webapp): B4 — a right-click menu on a workspace tile

The strip's tiles answered a left click and nothing else. A right click
now opens the same context menu the tab strip serves: the chrome is
`.webapp-session-menu`, positioned at the pointer and clamped to the
viewport, closed by Escape or by a click outside.

Three items, graded by §3:

- Rename, which edits the name in a field the popover holds — the tile
  is two letters wide — and writes it with the PATCH the settings tab
  already uses;
- Settings, which opens the details dialog on its Settings tab;
- Invite, which opens it on Members with the add-member picker focused.
  There is no email invite here: adding somebody means adding an
  existing member of the organization.

A member sees Settings alone. The left click is untouched, and a right
click never selects the tile it opened on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(webapp): B5 — "My machine" behind the rail's box icon

The rail header said what the workspace is and never what the member's
own machine is. A third button opens that panel, wearing the box glyph
`files/DriveIcons.tsx` has drawn since before #106.

The dialog is the workspace-details chrome with no tab row: one view,
because there is one machine to read. It names the state, the type and
what the type is (vCPU, RAM, disk, price, all off the existing catalog
helpers), whether a persistent volume is attached, and the machine's own
error where it has one.

The lifecycle controls carry the §3 matrix in the first person. Stop and
start are a member's own business; recreate, destroy and the machine-type
change are workspace-admin work, and provisioning where there is no
machine row at all is the member-add route in disguise. A verb this
member may not run is shown DISABLED and names who to ask — "Ask a
workspace admin: Ada Owner" — because there is no request to file and
pretending one was filed would be worse than saying who can act.

A viewer holds no machine (§2.2) and is told so.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(webapp): B6 — the rail's Members button wears the Drive share icon

The rail drew its own three-node share glyph while the Drive page drew a
person-with-a-plus for the same verb. The rail now imports the Drive
page's `ShareGlyph`, so one shape means "share" across the app, and the
strip's copy is deleted rather than left to drift — the rail was its only
caller. The button still opens Members and nothing else changed.

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.

2 participants