|
| 1 | +# Volume + Workspace Data Model |
| 2 | + |
| 3 | +Status: draft |
| 4 | +Owner: rowan |
| 5 | +Created: 2026-05-08 |
| 6 | +Updated: 2026-05-08 |
| 7 | + |
| 8 | +Pair plan: [`volume-workspace-surfaces.md`](volume-workspace-surfaces.md) (CLI + UI rollout). |
| 9 | + |
| 10 | +## Goal |
| 11 | + |
| 12 | +Introduce a two-layer information model: |
| 13 | + |
| 14 | +- **Volume** — one named, checkpointable, forkable file tree. Atomic unit of |
| 15 | + persistence and content history. Today's `WorkspaceMeta` renamed. |
| 16 | +- **Workspace** — a manifest composing one or more mounted volumes with |
| 17 | + per-volume permissions. Has no file content of its own. The natural unit |
| 18 | + of agent configuration. |
| 19 | + |
| 20 | +This is the substrate the surfaces plan builds on. |
| 21 | + |
| 22 | +## Scope |
| 23 | + |
| 24 | +**In scope:** |
| 25 | + |
| 26 | +- Go data model: rename `Workspace*` types to `Volume*`; introduce |
| 27 | + `Workspace*` (composition) types |
| 28 | +- Redis schema for volumes and workspace manifests |
| 29 | +- HTTP API: `/v2/volumes/...` and `/v2/workspaces/...` |
| 30 | +- Access token model: (scope, capability) two-axis design |
| 31 | +- Daemon: singleton-per-user-per-machine, multiplexes mount sessions |
| 32 | +- Workspace bookmarks (composition-level checkpoint references) |
| 33 | +- MCP backwards-compat layer for tool names and capability profile strings |
| 34 | +- Migration script + cutover plan for existing data and tokens |
| 35 | + |
| 36 | +**Out of scope:** |
| 37 | + |
| 38 | +- All user-facing surfaces (CLI, UI, copy, docs) — see surfaces plan |
| 39 | +- MCP wire identifier rename (`workspace_*` tools, `workspace-*` profile |
| 40 | + strings) — separate future plan |
| 41 | +- SDK rewrites beyond the minimum needed to back the new endpoints |
| 42 | +- Workspace templates (composition templates) — future |
| 43 | + |
| 44 | +## Concepts |
| 45 | + |
| 46 | +### Volume |
| 47 | + |
| 48 | +- Same shape as today's `WorkspaceMeta` (`internal/controlplane/store.go:51`) |
| 49 | +- Owns its checkpoint timeline, manifest, blobs, fork lineage |
| 50 | +- Addressable by `(database_id, volume_id)` |
| 51 | +- Volume IDs prefixed `vol_` going forward |
| 52 | + |
| 53 | +### Workspace |
| 54 | + |
| 55 | +- A manifest of mount entries, ordered for deterministic application |
| 56 | +- Owned by a database; addressable by `(database_id, workspace_id)` |
| 57 | +- Addressable IDs prefixed `ws_` going forward |
| 58 | +- Carries no file content; all content flows through mounted volumes |
| 59 | + |
| 60 | +### Mount entry |
| 61 | + |
| 62 | +``` |
| 63 | +{ |
| 64 | + volume_id: string, // which volume to mount |
| 65 | + mount_path: string, // relative path under workspace root, e.g. "/skills" |
| 66 | + readonly: bool, // forces ro even if token would allow rw |
| 67 | + volume_token_id?: string // optional, for cross-owner volume access |
| 68 | +} |
| 69 | +``` |
| 70 | + |
| 71 | +Mount paths are *relative*. The absolute root is supplied by the client at |
| 72 | +mount time, so the same workspace works across machines. |
| 73 | + |
| 74 | +### Mount conflict policy |
| 75 | + |
| 76 | +- Same `(volume_id, mount_path)` mounted twice → idempotent no-op |
| 77 | +- Same volume at different paths → two independent sessions, allowed |
| 78 | +- Two volumes at same `mount_path` within one workspace → reject at API write |
| 79 | +- Overlapping paths within one workspace (`/a` and `/a/b` from different |
| 80 | + volumes) → reject at API write |
| 81 | + |
| 82 | +## Access tokens |
| 83 | + |
| 84 | +Two orthogonal axes. |
| 85 | + |
| 86 | +### Scope |
| 87 | + |
| 88 | +| Scope | Reaches | |
| 89 | +|---|---| |
| 90 | +| `volume:<id>` | Exactly one volume | |
| 91 | +| `workspace:<id>` | All volumes mounted by that workspace, with manifest readonly applied | |
| 92 | +| `database:<id>` | All volumes and workspaces in the database | |
| 93 | +| `control-plane` | Everything in the user's control plane | |
| 94 | + |
| 95 | +### Capability |
| 96 | + |
| 97 | +| Capability | Allows | |
| 98 | +|---|---| |
| 99 | +| `ro` | Read files, read checkpoints | |
| 100 | +| `rw` | `ro` + write/edit files | |
| 101 | +| `rw-checkpoint` | `rw` + create/restore volume checkpoints, create/restore workspace bookmarks | |
| 102 | +| `admin` | `rw-checkpoint` + manage volumes, workspaces, tokens | |
| 103 | + |
| 104 | +### Rules |
| 105 | + |
| 106 | +- A token is `(scope, capability, optional expiry)` |
| 107 | +- Capability is uniform across the scope |
| 108 | +- Per-volume `readonly` in a manifest is enforced *in addition to* the |
| 109 | + token capability — a workspace `rw` token cannot write to a volume mounted |
| 110 | + readonly |
| 111 | +- Cross-owner mounts: when a workspace mounts a volume not owned by the |
| 112 | + workspace owner, the manifest entry must reference a `volume_token_id` |
| 113 | + granting access. Removing/expiring that volume token invalidates the |
| 114 | + mount entry but leaves the workspace intact |
| 115 | +- Multi-token agents: agents needing mixed access across multiple workspaces |
| 116 | + hold multiple tokens. No compound policy in a single token |
| 117 | + |
| 118 | +### Mapping from current MCP profiles |
| 119 | + |
| 120 | +| Today | Tomorrow | |
| 121 | +|---|---| |
| 122 | +| `workspace-ro` | scope `volume:<id>`, capability `ro` | |
| 123 | +| `workspace-rw` | scope `volume:<id>`, capability `rw` | |
| 124 | +| `workspace-rw-checkpoint` | scope `volume:<id>`, capability `rw-checkpoint` | |
| 125 | +| `admin-ro` | scope `control-plane`, capability `ro` | |
| 126 | +| `admin-rw` | scope `control-plane`, capability `admin` | |
| 127 | + |
| 128 | +The wire-level `workspace-*` profile strings remain valid identifiers in |
| 129 | +MCP for backwards compat. Internal Go switches to (scope, capability) |
| 130 | +tuples; a translation layer maps incoming legacy strings. |
| 131 | + |
| 132 | +## Checkpoints |
| 133 | + |
| 134 | +- **Volumes own checkpoint timelines.** No content history at the workspace |
| 135 | + layer. |
| 136 | +- A **workspace bookmark** is a named tuple |
| 137 | + `(workspace_id, name, [{volume_id: checkpoint_id}, ...])` capturing one |
| 138 | + head per mounted volume at bookmark time. |
| 139 | +- "Restore workspace bookmark X" iterates the recorded list and restores |
| 140 | + each volume to its referenced checkpoint. Atomic intent at the workspace |
| 141 | + level; canonical truth still at the volume. |
| 142 | +- Bookmarks soft-pin referenced checkpoints (volumes record back-references |
| 143 | + so checkpoint GC honors active bookmarks). |
| 144 | + |
| 145 | +## Daemon model |
| 146 | + |
| 147 | +- One daemon process per user per machine. Singleton; ref-counted lifecycle. |
| 148 | +- Daemon holds N mount sessions across any number of volumes and workspaces. |
| 149 | +- `afs vol mount` and `afs ws mount` register sessions with the same daemon. |
| 150 | + `ws mount` expands its manifest into N session registrations. |
| 151 | +- Per-session lease (existing model). On revocation/expiry, the affected |
| 152 | + session unmounts; other sessions continue. |
| 153 | +- Scaling: 100 mounts → 100 sessions in 1 process. Real cliffs are Redis |
| 154 | + pubsub fan-out and FUSE handle limits, not OS process count. |
| 155 | +- New ops surface: `afs daemon status`, `afs daemon stop` (see surfaces plan). |
| 156 | + |
| 157 | +## API surface |
| 158 | + |
| 159 | +New `/v2/` namespace. `/v1/workspaces` (current) keeps working through the |
| 160 | +deprecation window and reads identical data to `/v2/volumes` (same backing |
| 161 | +record). |
| 162 | + |
| 163 | +``` |
| 164 | +POST /v2/volumes |
| 165 | +GET /v2/volumes |
| 166 | +GET /v2/volumes/{id} |
| 167 | +DELETE /v2/volumes/{id} |
| 168 | +POST /v2/volumes/{id}:fork |
| 169 | +POST /v2/volumes/{id}:restore |
| 170 | +GET /v2/volumes/{id}/checkpoints |
| 171 | +POST /v2/volumes/{id}/checkpoints |
| 172 | +POST /v2/volumes/{id}/checkpoints/{name}:restore |
| 173 | +
|
| 174 | +POST /v2/workspaces |
| 175 | +GET /v2/workspaces |
| 176 | +GET /v2/workspaces/{id} |
| 177 | +DELETE /v2/workspaces/{id} |
| 178 | +PUT /v2/workspaces/{id}/mounts # bulk replace manifest |
| 179 | +POST /v2/workspaces/{id}/mounts # add one mount entry |
| 180 | +DELETE /v2/workspaces/{id}/mounts/{volume_id} |
| 181 | +GET /v2/workspaces/{id}/bookmarks |
| 182 | +POST /v2/workspaces/{id}/bookmarks |
| 183 | +POST /v2/workspaces/{id}/bookmarks/{name}:restore |
| 184 | +``` |
| 185 | + |
| 186 | +Token operations gain `scope` and `capability` fields; the legacy `profile` |
| 187 | +field is read-only and computed from (scope, capability) for compat. |
| 188 | + |
| 189 | +## Redis schema |
| 190 | + |
| 191 | +Today: |
| 192 | + |
| 193 | +``` |
| 194 | +afs:database:{db}:workspace:{id} |
| 195 | +afs:database:{db}:workspace:{id}:savepoints |
| 196 | +``` |
| 197 | + |
| 198 | +After: |
| 199 | + |
| 200 | +``` |
| 201 | +afs:database:{db}:volume:{id} # was workspace:{id} |
| 202 | +afs:database:{db}:volume:{id}:checkpoints |
| 203 | +afs:database:{db}:workspace:{id} # NEW shape: composition |
| 204 | +afs:database:{db}:workspace:{id}:bookmarks |
| 205 | +``` |
| 206 | + |
| 207 | +The `workspace:{id}` namespace is reused for the *new* concept. Migration |
| 208 | +moves existing data out of that key first. |
| 209 | + |
| 210 | +## Migration |
| 211 | + |
| 212 | +Breaking change at API and Redis-key level. Mitigated via: |
| 213 | + |
| 214 | +1. **Versioned API.** `/v1/workspaces` continues during the deprecation |
| 215 | + window, served by a compat layer that reads from the new volume keys. |
| 216 | + `/v2` is authoritative. |
| 217 | +2. **One-shot Redis migration script.** For each existing workspace: |
| 218 | + - Copy `WorkspaceMeta` payload to a new `volume:{id}` key |
| 219 | + - Rewrite token records from `workspace:<id>` scope to `volume:<id>` scope |
| 220 | + - Leave the old `workspace:{id}` key in place for the duration of the |
| 221 | + deprecation window, then delete after cutover |
| 222 | +3. **No automatic workspace creation.** Migration does not auto-create a |
| 223 | + composition workspace per existing volume. Volumes work standalone via |
| 224 | + `afs vol mount` for users who don't need composition. |
| 225 | +4. **Token compat.** Existing MCP tokens preserve effective access. Their |
| 226 | + wire `profile` string is unchanged; a server-side mapping populates |
| 227 | + (scope, capability) on read. |
| 228 | + |
| 229 | +## Phases |
| 230 | + |
| 231 | +### Phase 1 — Internal types and storage (3–4 days) |
| 232 | + |
| 233 | +- [ ] Rename `WorkspaceMeta` → `VolumeMeta` in `internal/controlplane/` |
| 234 | +- [ ] Rename `WorkspaceSessionRecord` → `VolumeSessionRecord` (sessions |
| 235 | + remain volume-bound; workspace sessions are aggregations) |
| 236 | +- [ ] Add `WorkspaceMeta` (new) carrying the manifest and bookmarks |
| 237 | +- [ ] Update Redis key constants; add new key shape for compositions |
| 238 | +- [ ] Update `internal/controlplane/store.go`, `service.go` accordingly |
| 239 | +- [ ] Backwards-compat read shim: `/v1/workspaces` reads from new volume |
| 240 | + keys without exposing the new shape |
| 241 | + |
| 242 | +### Phase 2 — API endpoints (3–4 days) |
| 243 | + |
| 244 | +- [ ] `/v2/volumes/...` endpoints (mostly direct rename of `/v1/workspaces`) |
| 245 | +- [ ] `/v2/workspaces/...` endpoints (NEW) |
| 246 | +- [ ] Manifest CRUD: PUT/POST/DELETE on `/v2/workspaces/{id}/mounts` |
| 247 | +- [ ] Bookmarks: list, create, restore |
| 248 | +- [ ] Validation: mount-path uniqueness, path overlap rejection, |
| 249 | + cross-owner volume_token_id enforcement |
| 250 | +- [ ] HTTP integration tests |
| 251 | + |
| 252 | +### Phase 3 — Token model (2–3 days) |
| 253 | + |
| 254 | +- [ ] Add `Scope` and `Capability` fields on `mcpAccessTokenRecord` |
| 255 | +- [ ] Compute `Profile` (legacy field) from (scope, capability) for compat |
| 256 | +- [ ] Token issuance API accepts new fields; old `profile` accepted for compat |
| 257 | +- [ ] Capability ladder enforcement in HTTP handlers and MCP tool gating |
| 258 | +- [ ] Manifest readonly enforcement (workspace token + volume mount readonly |
| 259 | + = effective ro) |
| 260 | + |
| 261 | +### Phase 4 — Daemon multiplexing (4–5 days) |
| 262 | + |
| 263 | +- [ ] Daemon refactor to host N concurrent mount sessions |
| 264 | +- [ ] Singleton lifecycle with reference counting |
| 265 | +- [ ] `afs daemon status` and `afs daemon stop` support endpoints |
| 266 | +- [ ] Lease watchdog per session; one session failure does not affect others |
| 267 | +- [ ] Stress test at 50 and 100 concurrent mounts |
| 268 | + |
| 269 | +### Phase 5 — Migration (2–3 days) |
| 270 | + |
| 271 | +- [ ] Redis migration script (idempotent; safe to rerun) |
| 272 | +- [ ] Token scope rewrite (`workspace:<id>` → `volume:<id>`) |
| 273 | +- [ ] Compat read layer for `/v1/workspaces` |
| 274 | +- [ ] Cutover runbook (staging → production) |
| 275 | +- [ ] Deprecation window: 90 days from launch, then `/v1/workspaces` 410s |
| 276 | + |
| 277 | +### Phase 6 — MCP compat (1–2 days) |
| 278 | + |
| 279 | +- [ ] Add `volume_*` tool aliases for `workspace_*` tools; both route to the |
| 280 | + same handlers against volumes |
| 281 | +- [ ] Add new `workspace_compose_*` tools (names tentative; review before |
| 282 | + merge): `_create`, `_mount_add`, `_mount_remove`, `_show`, |
| 283 | + `_bookmark_create`, `_bookmark_restore` |
| 284 | +- [ ] Profile-string ↔ (scope, capability) translation in |
| 285 | + `mcp_profiles.go` |
| 286 | + |
| 287 | +## Open questions |
| 288 | + |
| 289 | +- **Bookmark retention semantics.** Hard-pin or soft-pin? Recommend soft-pin |
| 290 | + with explicit warning when GC would orphan a bookmark. |
| 291 | +- **Workspace ownership transfer.** When a workspace is shared, are embedded |
| 292 | + volume_token_ids transferred? Recommend no — receiver supplies their own |
| 293 | + tokens for any volumes they don't already own. |
| 294 | +- **Empty workspaces.** Allowed? Recommend yes. |
| 295 | +- **Mount-path namespace policy.** Reject overlap (`/a` and `/a/b`)? |
| 296 | + Recommend yes; ship as a hard validation error. |
| 297 | +- **`/v1/workspaces` deprecation horizon.** Recommend 90 days. Confirm with |
| 298 | + ops/comms. |
| 299 | +- **Daemon location.** Per-user-per-machine vs per-machine. Recommend |
| 300 | + per-user — cleaner permission boundaries. |
0 commit comments