<!-- source: openspec/current/studio-workspace/spec.md -->

---
title: Studio Workspace — Spec
status: stable
owner: pavel
audience: [pavel, ai, engineer]
tldr: Behavioural contracts for the workspace — surface area, route tables, API endpoints, and all applied amendments.
---
# Studio Workspace — spec

> Behavioural contracts: surface area, route tables, API endpoints, and all applied amendments.
> Why / context in [`narrative.md`](narrative.md). Flows in [`flows.md`](flows.md). Entities in [`entities.md`](entities.md).

---

> **RAP-1170 Wave 1 (2026-05-22) — schema rename.** Every reference
> below to the `canvas_*` family of tables (`canvas_sessions`,
> `canvas_messages`, `canvas_files`, `canvas_invitees`,
> `canvas_attachments`, `canvas_instructions`, `canvas_tool_uses`,
> `canvas_stage_events`, `canvas_daily_costs`, `canvas_ideas`) was
> dropped and replaced by the project → spec → idea hierarchy. The
> route-level surface (`/spec/[id]`, the route group `(spec)` —
> renamed from `(canvas)` in #1661, the action menus, the file
> tree) is preserved across the rename; the DB anchor is now `ideas`
> instead of `canvas_sessions`. See `db/entities.md § Project → spec
> → idea hierarchy` for the canonical entity catalog. The historical
> text below uses pre-Wave-1 names to preserve the chronology of how
> the surface was built; treat any `canvas_<table>` reference as a
> pointer to its post-Wave-1 equivalent (`ideas`, `messages`,
> `files`, `spec_invitees`, etc.).

> **RAP-1252 (2026-05-28) — canvas vocabulary retired.** The word `canvas`
> is fully retired from the workspace surface across code identifiers,
> user-facing copy, and the package layer per the `retire-canvas-vocabulary`
> change (archived 2026-05-28 as `openspec/archive/2026-05-28-retire-canvas-vocabulary/`).
> The working-session entity is **`idea`** (`apps/studio/src/components/idea/`,
> `IdeaView`, `canvasId → ideaId`); the deliverable doc set is **`spec`**;
> the user-facing word is **"Spec"** per D-CV-1. Atlas D-ATLAS-5 was extended
> with two new `grep-forbidden` guards (CV-A: no `Canvas*` symbol declarations
> in `apps/studio/src/`; CV-B: no `canvas`/`canvases` JSX text in
> `apps/studio/src/`). The prose below that still uses `canvas` as a noun
> for the working surface predates this rename; the canonical mapping is
> `canvas surface → spec surface (user-facing) / idea surface (entity-internal)`,
> `canvas-view.tsx → idea-view.tsx`, `canvasMachine → ideaMachine`, etc.
> Documentation drift across the spec.md prose is accepted as a
> known-limitation; future surface amendments will land with current vocab.

---

## Surface area

Workspace surface at `app.rapoport.studio` includes auth-walled routes (the four-row table below), public auth-flow routes (`/login`, `/auth/callback`) owned by `auth.md`, and a dev-only diagnostic route at `/token-check` (route group `(dev)/`) for JWT/cookie inspection during development. The marketing apex at `rapoport.studio` (the public site) is owned by `web.md`. Both subdomains share the `@rapoport-studio/auth` package via cross-subdomain cookie domain — see `auth.md § Cookie domain handling`.

Studio routes start at the root of the subdomain (`app.rapoport.studio/<route>`). `ROADMAP.md` notation occasionally uses `/app/*` as informal shorthand for "the auth-walled segment"; the canonical route shapes live here.

| Route | Group | Purpose |
|---|---|---|
| `/` | `(auth-walled)/(default)` | Dashboard — at-a-glance: KPIs, recent specs, "New spec" entry |
| `/specs` | `(auth-walled)/(default)` | Specs list — pending invitations + user's own specs. Renamed from `/canvases` in #1420; underlying entity remains `canvas_sessions`. |
| `/specs/[...slug]` | `(auth-walled)/(default)` | OpenSpec markdown viewer — catch-all that resolves `slug` to `current/<slug>.md` in `OPENSPEC_BUNDLE`. Citation deep-link target. Top-right `<a download>` icon links to the public archive at `rapoport.studio/openspec-flat.zip` — see § Spec viewer download affordance. |
| `/spec/[id]` | `(auth-walled)/(spec)` | Architect mode chat + proposal/design/tasks artifact panel — see `muse.md § Architect mode (v1)` |
| `/settings` | `(auth-walled)/(default)` | Profile, API key, appearance |
| `/control` | `(auth-walled)/(default)/control` | Control room — owner-only operational dashboards index. See § Control room. |
| `/control/roadmap` | `(auth-walled)/(default)/control/roadmap` | Roadmap dashboard — parses `openspec/ROADMAP.md` into KPIs, critical-path tracks, kanban, drift items. Owner-only. |
| `/control/engagements` | `(auth-walled)/(default)/control/engagements` | Engagement overview — one row per `projects` joined with `_methodology/projects/<slug>.yaml` + activity signals (canvas + forge). Owner-only. See § Control room § Engagements dashboard. |
| `/network` | `(auth-walled)/(default)/network` | Member-only network surface — full-data variants of the apps/web read sections plus three studio-only sections (Discussions, Pending invitations, Skill gap) and write affordances on Open calls / Discussions / Channels. See § Network surface. |
| `/profile/specialist` | `(auth-walled)/(default)/profile/specialist` | Specialist self-service profile — editable fields: `disciplines`, `domains_of_interest`, `availability`, `language_preferences`, `prompt_augmentations_uri`; read-only: `nda_status`, `certification_level`, `certified_at`. Visible only when the authenticated user has a `specialists` row. |
| `/specialists/apply` | `(public)/specialists/apply` | Public intake form — all 14 fields per `design.md § 4.2`; submits via POST to `/api/studio/specialist-application`; magic-link auth; `?ref=` querystring pre-fills `referred_by`. |
| `/specialists/apply/thanks` | `(public)/specialists/apply/thanks` | Post-submission acknowledgement screen. |
| `/inbox/specialist` | `(auth-walled)/(default)/inbox/specialist` | `platform_owner`-only list of pending specialist applications with state badges. |
| `/inbox/specialist/[id]` | `(auth-walled)/(default)/inbox/specialist/[id]` | Detail view for one application; action bar runs the full state machine: `markReviewing → markShadowing → proceedToNda → markNdaSigned → recordCertification / decline`. All transitions stamp `reviewed_at`, `reviewed_by`, `decision_at`. |

### Public auth-flow routes

Owned by `auth.md`; listed here so the surface map reads complete:

| Route | File | Purpose |
|---|---|---|
| `/login` | `apps/studio/src/app/(auth)/login/page.tsx` | Magic-link login form |
| `/auth/callback` | `apps/studio/src/app/auth/callback/route.ts` | Supabase code-exchange, invitee acceptance, post-login redirect — see `auth.md § Magic-link sign-in` step 7 |

### Dev-only routes

| Route | File | Purpose |
|---|---|---|
| `/token-check` | `apps/studio/src/app/(dev)/token-check/page.tsx` | Brand + status token resolution check. `notFound()` in production; visible only when `NODE_ENV !== "production"`. Listed in `apps/studio/src/middleware.ts` `publicRoutes` so the auth proxy lets the page render before the production guard runs. Used during RAP-22 / RAP-23 to verify Tailwind utilities resolve to the expected CSS variables. |

### API surface

| Route | File | Status | Auth | Purpose |
|---|---|---|---|---|
| `/api/studio/leads` | `apps/studio/src/app/api/studio/leads/route.ts` | v1, live | API-key (`x-api-key`) | Lead capture endpoint — receives forwarded payloads from `/api/intake` on `rapoport.studio` (the `apps/web` `/contact` form). Validates the strict zod schema (engagement_interest, project_type, timeline, brief, etc.), persists to Supabase via the admin client, returns `{ ok: true }`. Hardened by zod validation, body-size cap (`MAX_BODY_BYTES = 65536`), and a Cloudflare Turnstile token verified before insert. Wired via the `add-client-onboarding` change. |
| `/api/studio/intake` | `apps/studio/src/app/api/studio/intake/route.ts` | v1, foundational stub | API-key (`x-api-key`) | n8n-driven Telegram bridge endpoint. POST normalized payloads (`message`, `from`, `chat_id`, `trigger`, etc.) authenticated via `x-api-key` against the `STUDIO_COMMAND_API_KEY` worker secret. Phase 6 (RAP-81) will wire intent parsing + Linear writes; today the handler logs and returns `{ ok: true, received: true }`. |
| `/api/studio/command` | `apps/studio/src/app/api/studio/command/route.ts` | v1, live | API-key (`x-api-key`) | "Digital Pavel" chat assistant for the Telegram bot. Calls Claude with a Pavel-shaped system prompt; supported actions are `create_task` / `read_tasks` (Linear) and free-text reply. Lives at the studio because it shares the same `STUDIO_COMMAND_API_KEY` and Linear/Anthropic credentials as the rest of the platform. |
| `/spec/[id]/turn` | `apps/studio/src/app/(auth-walled)/(spec)/spec/[id]/turn/route.ts` | v1, live | session (auth-walled) | SSE endpoint that streams a single Architect turn — the Muse runtime stream consumed by `idea-view.tsx`. See § Mirror runtime in spec. |

The earlier `/api/auth/*` and `/api/canvas/*` shapes the spec previously claimed do not exist. Magic-link callbacks live at `/auth/callback` (see § Public auth-flow routes above). **Canvas mutation in v1 happens via Next.js Server Actions** (`(auth-walled)/(spec)/spec/[id]/actions.ts`), not REST — a stable `/api/canvas/*` REST surface is v2 work, see `narrative.md § Out of scope (until v2)`. The Telegram/WhatsApp bridge endpoints (`/api/studio/{intake,command}`) are **v1** today — they ship as foundational scaffolding so the n8n bridge has a stable URL to POST to; production behavior expansion is tracked under `narrative.md § Out of scope (until v2)`.

---

## Spec page surface

> Canonical section for what `/spec/[id]` renders. Six panes; every
> pane links into the deep contract that owns it. If you change the
> page-level composition, you change this section first; the deep
> contracts follow.
>
> Ratified by `openspec/archive/2026-05-21-consolidate-spec-page-surface/`
> (`D-SP-1..7`).

### Identity & routing

| Field | Value |
|---|---|
| Route | `/spec/[id]` |
| Group | `(auth-walled)/(spec)` — full-bleed; no default topbar |
| Page file | `apps/studio/src/app/(auth-walled)/(spec)/spec/[id]/page.tsx` |
| Sidebar active | *Canvases* entry when `pathname.startsWith("/spec/")` |
| Frontend tier | HOT (realtime via `supabaseRealtimeBridge`) |
| State spine | XState v5 `ideaMachine` + five children (realtime, chat, instructions, participants, costBudget) |

### Composition — six panes

Left to right, top to bottom:

| Pane | What renders | Deep contract |
|---|---|---|
| Shell | Single labeled `<Sidebar>` — three groups (Workspace · Build · Admin), nine entries, `BrandMark` header, `UserMenu` footer | § Workspace sidebar |
| Left rail · Threads | Project-scoped chronological timeline of the project's changes. Per-row: title, relative time, P · D · T triplet-presence indicator, visual-only `draft` approval status pill (placeholder; the original `add-canvas-change-approval` scaffold was cancelled by RAP-1168 Wave 7 — approval data-binding will return under the new project → spec → idea model when the post-rearchitecture approval change ships) | § Threads rail — project change timeline |
| Header strip | Single-row H3 header ≤ 64 px. Left: canvas glyph + title + branch + slug + mode chip. Right action cluster, in order: `<StageProgressView>` stepper, conditional state chips, icon-only 32 × 32 action pills (Discovery, Invite via Telegram, Save all pending (N), Open/View PR), `⋯` overflow, chat-width segmented toggle (compact / normal / wide) | § Spec file surface — Surface; § Spec chat-width preset toggle; `redesign-canvas-chrome` |
| Chat column | Turn-by-turn stream, `<ArtifactDiffBlock>` inline per assistant message, `linked_idea_id` chip under `intake_brief` / architect-turn messages. Width: `max-width: var(--chat-max-w)` driven from `user_settings.layout.canvasChatWidth`. The column is a *view registry host*; default view is `chat`; the second `?view=ideas` view slot is reserved (the original `add-ideas-rail-view` scaffold was cancelled by RAP-1168 Wave 7 — Ideas-rail rendering returns under the new project → spec → idea model when the rebased change ships, D-SP-2) | muse.md § UI surface; § Spec view registry |
| Composer | Multi-line textarea (Enter sends, Shift-Enter newlines). Three smart pills above the textarea, in order: **Templates**, **Suggest a question**, **Pin the brief** (D-SP-4). Mention affordances: `/`-prefix for canvas members + file paths, `@`-prefix for project-team handles (D-SP-5). Persona stamp + cost meter at footer | *canonical here* — D-SP-4, D-SP-5 |
| Right rail · Change (single block) | One Change block. Two chrome strips before content (D-URC-1..6, 2026-05-29): (1) caption row hosting `Change · <status>` + `<ModeSwitchStrip>` (preview / source / diff) + `<CopyArtifactButton>` (contextual icon, no Download), (2) `<ArtefactTabs>` (proposal · design · tasks · verification). Body is `<ArtifactPanel>` rendering `<MediaBlock chrome="none">` so its inner filename/mode/actions row disappears and content sits edge-to-edge inside `TabsContent`. Below the panel: `<CitationsPanel>` + `<ChangeFooter>`. A 44 px `<IconDock>` at the inner edge holds `§` Spec / `⌗` Code glyphs that open `<SpecDrawer>` / `<CodeDrawer>` as overlays over the chat column (never restructure the rail); `Cmd+\` toggles the most-recently-opened drawer. URL contract: `?rail=change` (only canonical) + `?drawer=spec\|code` (mutually exclusive) + `?tab=<artefact>` + `?mode=preview\|source\|diff`. 30-day legacy redirect maps `?rail=spec\|code\|change,spec,code` (D-RRR-9). Supersedes D-SP-3 — see D-RRR-1..13 and D-URC-1..6 amendments | muse.md § Canvas surface; D-RRR-1..13; D-URC-1..6 |

### Cross-cutting contracts

| Contract | Authority |
|---|---|
| Lifecycle stages (six, 0–100 percent) | § Spec lifecycle stages |
| Cost budget ($5 cap, pre-flight refuse) | muse.md § Cost budget |
| Canvas modes (`draft / project / legacy_promoted`) | § Spec file surface — modes |
| Realtime state spine + reconnect resilience | § Spec state orchestration |
| Overlay host placement (sibling of `<SidebarProvider>`) | § Workspace sidebar — Composition |

### Forward-compat slots

| Slot | Proposed change | Status |
|---|---|---|
| Resizable panel boundaries (drag handles, per-device localStorage) | `add-resizable-chat-layout` | proposed |
| Preview view-mode (`?view=preview`, mirror surface) | `add-canvas-preview-mode` | proposed |
| Approval status data-binding (Threads pill stops being a placeholder) | TBD post-rearchitecture (original `add-canvas-change-approval` cancelled by RAP-1168 Wave 7) | reserved |
| Spec-navigator (in-document nav inside the right rail's Spec block) | `add-spec-navigator` | proposed |

Each slot is reserved here so its archive amendment lands cleanly under a
named subsection rather than accreting new top-level sections to this file.

---

## Canvas creation gate (applied 2026-05-02 from change `add-client-role`)

The «New canvas» CTA on dashboard `/` and `/canvases` is conditionally rendered based on `auth.users.raw_app_meta_data.platform_owner`. Non-platform-owners (orphan accounts, future clients) see no creation entry point. Studio's operating model is now architecturally enforced: canvases are studio-managed engagements created by platform owners (currently `pavel.rapoport@gmail.com` and `hello@rapoport.studio`).

---

## Mirror runtime in canvas (applied 2026-05-03 from change `wire-muse-runtime-in-studio`)

Closes the Mirror end-to-end loop after RAP-32 / RAP-33 / RAP-34 shipped infrastructure. The studio canvas runtime now drives `runMuseWithTools` end-to-end, threads viewer + project context into `ToolUseLoopContext`, and persists Mirror authoring tool calls during a Discovery interview.

### Runtime swap

- `apps/studio/src/app/(auth-walled)/(spec)/spec/[id]/actions.ts` `runTurn` no longer calls `runArchitect`. Each turn:
  1. Loads canvas state including the new `projectSlug` field.
  2. Re-derives `viewerRole` + `viewerPersona` (`// TODO: cache viewer in canvas state load (v2 perf)` — current behavior re-runs `loadCanvasParticipants` per turn, duplicating page-server-side work).
  3. Builds a `ToolUseLoopContext` via `buildToolUseLoopContext` (`canvas/[id]/tool-use-context.ts`). The factory re-validates `project_slug` against the studio-boundary allowlist (`isValidProjectSlug`) — defense in depth against any DB CHECK bypass.
  4. Calls `runMuseTurn` (`canvas/[id]/muse-runner.ts`), which drains the `runMuseWithTools` async generator, collects text deltas + tool-result errors, and returns the populated architect artifact sink.
  5. Persists user + assistant messages and any emitted artifacts (existing UPSERT-on-emit flow preserved).

### `project_slug` on canvas creation

- `NewCanvasButton` is a Dialog with an optional `project_slug` text input. Empty input → canvas created with `project_slug = NULL` (architect-only). Non-empty → validated against `isValidProjectSlug`, persisted to `canvas_sessions.project_slug`. Server action `createCanvasAction` re-validates and returns a `CreateCanvasResult` so the dialog renders inline errors without unmounting.
- Settings-panel edit for existing canvases is **out of scope for v1** — the slug is set-at-creation and immutable.

### Mirror authoring on Workers (closed 2026-05-05 by RAP-71)

- The earlier "Mirror authoring requires local dev currently." banner — surfaced when `write_mirror_field` returned `not_available_in_runtime` on Cloudflare Workers — was removed by RAP-71 (Track A of `complete-canvas-file-lifecycle`). The tool no longer touches `node:fs/promises`; instead it calls a `mirrorWriter` callback supplied by `ToolExecutionContext`. The studio backs the callback with a `canvas_files` UPSERT keyed by `(canvas_id, openspec/brands/<slug>/<field>.md)` so Mirror authoring round-trips end-to-end on Workers.
- `idea-view.tsx` no longer watches `toolErrors` for `not_available_in_runtime`. The banner code was retired alongside RAP-71.
- The Mirror perception markdown shows up in the canvas file-tree pane (RAP-74) under `openspec/brands/<slug>/identity.md` exactly like any other path-keyed canvas file.

### Known limitations (carried forward as separate work)

- **`emitStatusEvent` no-op for tool execution.** Tool-status streaming UI is deferred — the canvas-view shows nothing during a 5–30 second tool execution. A streaming UI change is tracked separately.
- **Token cost is not credited to `tool_cost_cents`.** Only tool `cost_cents` accumulates today via `executeToolWithGuards`. Surfaced in PR description as a known v2 budgeting gap. **Resolved 2026-05-04 — see § Token cost credit below.**
- **Per-turn viewer re-derivation.** `loadCanvasParticipants` runs server-side both at page load and per turn. Acceptable for v1; tracked via the `// TODO: cache viewer in canvas state load (v2 perf)` comment in `actions.ts`.
- **Cloudflare CI deploy is broken** (RAP-6). After merge, a manual `pnpm --filter @rapoport-studio/studio run deploy` is required.
- **Pending invitations not visible to invitees.** Resolved 2026-05-04 — see § Pending invitations surface below.

---

## Pending invitations surface (applied 2026-05-04 from change `add-pending-invitations-surface`)

Closes the UX gap where invitees miss pending canvas invitations if the magic-link auto-accept doesn't fire (already-logged-in, link-forgotten, or regular-login scenarios). Confirmed live state before this change: 3 pending invitations in DB, none visible in UI.

### What ships

The `/canvases` page now renders a **pending invitations section above the owned canvases list**. The section is hidden entirely when N = 0 (no empty-state placeholder).

Each row shows: canvas display name (title or slug fallback), inviter email, role, persona, invited-at relative time ("3h ago"), and an [accept] button. No decline button in v1 — deferred to v2.

### Data path

`loadPendingInvitationsForViewer(supabase, viewerEmail)` in `apps/studio/src/app/(auth-walled)/(default)/canvases/loaders.ts` queries `canvas_invitees JOIN canvas_sessions` where `invitee_email = viewer_email AND accepted_at IS NULL`, ordered by `invited_at DESC`. Match is by email (not user_id) so pre-account invitations surface correctly before the invitee first logs in via magic link.

### Accept action

`acceptInvitationAction(invitationId)` in `canvases/invitation-actions.ts`:
- Validates: auth, `invitee_email` match (defense-in-depth vs RLS), `accepted_at IS NULL` (idempotent — concurrent magic-link accept is treated as success)
- Sets `accepted_at = NOW()`, `invitee_user_id = viewer.id` with a race guard (`WHERE accepted_at IS NULL`)
- Calls `revalidatePath('/canvases')` and returns `{ ok: true, canvasId }`
- UI redirects to `/spec/{canvasId}` on success; shows `sonner` toasts on error cases (`not_found`, `wrong_email`, `auth_failed`, `db_error`)

### Brand voice

Atelier — calm, no urgency colors. Lowercase section heading "invitations", "(N pending)" counter in secondary/muted color, primary-color accept button matching the existing "New canvas" CTA style.

### Out of scope (v2)

- Decline button (`declined_at` column)
- Real-time subscription (Supabase Realtime) — refresh on page load only
- Dedicated `/invitations` route
- Notifications panel (bell icon)
- Pending badge on sidebar

---

## Token cost credit (applied 2026-05-04 from change `credit-token-cost-to-canvas-budget`)

Closes the v2 budgeting gap noted in § Mirror runtime in spec → Known limitations. The studio canvas runtime now reloads prior cost, refuses pre-flight when over cap, and observes the per-turn cost event for future UI consumption.

### Pre-flight refuse with structured error

`sendMessageAction` in `apps/studio/src/app/(auth-walled)/(spec)/spec/[id]/actions.ts` checks `stateBefore.session.costCents >= 500` immediately after `loadCanvasState`. On exceed it returns `{ ok: false, error: "canvas_budget_exceeded" }` without persisting the user message and without invoking `runMuseWithTools`. The chat UI renders the error string as a regular error message — no remediation affordance lands here ("extend budget" / reset is RAP-19 territory).

### Reload prior cost into loop context

`loadCanvasState` selects `cost_cents` from `canvas_sessions` and surfaces it as `session.costCents` on the loaded state. `buildToolUseLoopContext` threads it into `ToolUseLoopContext.currentCanvasCostCents`, replacing the prior hardcoded `0`. The new tool-cost `.update({ cost_cents: newTotalCents })` writes the running total back through the same callback the muse runtime calls per-tool and per-turn. Single-turn-per-canvas concurrency is assumed (server action awaited, no parallel turn dispatch).

### Per-turn cost event observed

`muse-runner.ts` consumes the new `turn_cost` event from `runMuseWithTools` with a no-op branch — wiring is in place, UI surface is RAP-19. The event carries `cents` and the raw `Anthropic.Usage` block for any future per-turn breakdown.

---

## Studio inbox surface (applied 2026-05-04 from change `add-client-onboarding`)

Adds a `/inbox` section to `app.rapoport.studio` for processing leads submitted via the intake API.

### /inbox list

`apps/studio/src/app/(auth-walled)/(default)/inbox/page.tsx` — server component, platform_owner guard (redirect to `/` on fail). Reads `?status=` query param and calls `loadInboxRows({ status })`. Renders `<InboxStatusFilter>` (client component with `<select>` that pushes `?status=` via router) and a list of `<InboxRowItem>` components. Empty state: "No leads yet." mono text.

`apps/studio/src/app/(auth-walled)/(default)/inbox/loaders.ts` — `loadInboxRows({ status? })` and `loadInboxRow(id)`. Uses authenticated Supabase client (RLS: platform_owner only). Orders by `arrived_at DESC`. Status coerced against `VALID_STATUSES` set to prevent SQL injection via searchParam.

### /inbox/[id] detail

`apps/studio/src/app/(auth-walled)/(default)/inbox/[id]/page.tsx` — server component, platform_owner guard, `notFound()` on missing row. Renders `<InboxDetail row={row} />`.

`apps/studio/src/components/inbox/inbox-detail.tsx` — client component (uses `useTransition`). Layout: breadcrumb → header (name / email / arrived_at) → 2-col field grid (engagement_interest, project_type, timeline, company?, role?, source?) → brief section (pre-wrap) → metadata section (locale, referrer, ip_address with "anonymized after 90 days" note, user_agent) → status badge → action bar. Action bar by status: `new` → Mark reviewing + Decline + Accept →; `reviewing` → Decline + Accept →; `accepted_pending_invite` → retry copy + Retry invite →; `accepted` → canvas link only; `declined` → no actions. Accept/Retry open `<CanvasCreateDialog mode="from-inbox">`.

### CanvasCreateDialog two-mode refactor

`apps/studio/src/components/canvas/canvas-create-dialog.tsx` — union-typed props (`manual | from-inbox`). Manual mode: identical to former `NewCanvasButton` inline dialog body. From-inbox mode: displays lead info (read-only), `engagementType` select (defaults from `lead.engagementInterest`; `'unsure'` maps to `'discovery'`), `role` radio (client/collaborator, default=client), optional `project_slug`. Retry mode (`existingCanvasId` set): hides project_slug, CTA "Send invite". Calls `acceptLeadAction` (from-inbox) or `createCanvasAction` (manual). Toasts per error matrix. Redirects to `/inbox` on `lead_not_found` / `already_terminal`.

`apps/studio/src/components/dashboard/new-canvas-button.tsx` — reduced to a thin wrapper: button trigger + `<CanvasCreateDialog mode="manual" ...>`.

### Inbox server actions

`apps/studio/src/app/(auth-walled)/(default)/inbox/actions.ts`:

- `acceptLeadAction(inboxId, input)` — M6 non-atomic state machine. Step 0: SELECT inbox row; guard `lead_not_found` / `already_terminal`. Step 0b: if `accepted_pending_invite` and `canvas_id_if_accepted` set → skip step 1 (retry path). Step 1: call `createCanvasAction` → UPDATE inbox status to `accepted_pending_invite`. Step 2: call `inviteToCanvasAction` → UPDATE status to `accepted`. Returns `{ ok: true; canvasId; status }` or `{ ok: false; error; canvasId? }`. Non-atomic by design: the `accepted_pending_invite` intermediate state is observable and retryable via the Retry invite button.
- `declineLeadAction(inboxId)` — UPDATE `status = 'declined'`. Guards: rejects if already in `accepted`, `declined`, or `accepted_pending_invite`.
- `markReviewingAction(inboxId)` — UPDATE `status = 'reviewing'`. Guard: only from `'new'`.

### Navigation rail

`apps/studio/src/components/studio-rail.tsx` — added inbox entry ("I") between Canvases and Settings. Active when `pathname === '/inbox'` or `pathname.startsWith('/inbox/')`.

---

## Knowledge system (applied 2026-05-04 from change `add-muse-knowledge-system`)

### Project instructions panel

`apps/studio/src/components/canvas/InstructionsPanel.tsx` — client component, opened via a "Instructions" toggle button in the canvas chat header (visible to owner + collaborator; not rendered for clients). Shows a sorted list of `canvas_project_instructions` (pinned first, then by creation date). Owner can add/pin/delete any row; collaborator can add and delete own rows. Kind selector: vocabulary / preference / constraint / context.

Server actions in `actions.ts`:
- `addProjectInstructionAction(canvasId, content, kind)` — INSERT into `canvas_project_instructions`.
- `pinProjectInstructionAction(id, pinned)` — UPDATE `pinned` column (owner only).
- `deleteProjectInstructionAction(id)` — DELETE (owner: any row; collaborator: own only).

### /instruct command

Messages beginning with `/instruct ` are detected in `turn/route.ts` before the Muse turn runs. The user message is inserted into `canvas_messages` with `kind='instruction'` instead of the default `'message'`. The message renders in the chat with a distinct "Instruction" badge chip and a tinted background.

### Templates surface

`apps/studio/src/app/(auth-walled)/(default)/templates/` — two new pages:

**`/templates`** — list page. Shows all `studio_prompt_templates`. Platform owners see a "New template" button and edit links. All authenticated users see the list (for apply). Variable names extracted from each template's content are shown as badges.

**`/templates/[id]`** — editor page. Redirects non-owners. The slug `new` routes to create mode; any other value loads the existing template. Live variable detection: `{variable_name}` patterns extracted from content in real time.

Server actions in `templates/actions.ts`:
- `listTemplatesAction()` — SELECT ordered by `usage_count DESC, created_at DESC`.
- `createTemplateAction(slug, name, description, content)` — INSERT + extract variables.
- `updateTemplateAction(id, { name?, description?, content? })` — UPDATE; re-extracts variables when content changes.
- `applyTemplateAction(templateId, variableValues)` — substitutes `{variable_name}` placeholders, increments `usage_count`, updates `last_used_at`. Returns rendered content to the caller for injection as a user message.

`apps/studio/src/components/canvas/ApplyTemplateModal.tsx` — dialog opened from a "Templates" SmartPill in the canvas composer. Lists templates, renders variable inputs on selection, calls `applyTemplateAction`, injects the substituted content as a user message via `onApply(content)`.

Navigation rail: "T" entry added to `studio-rail.tsx` for `/templates`.

### Knowledge dashboard

`apps/studio/src/app/(auth-walled)/(spec)/spec/[id]/knowledge/page.tsx` — server page. Accessible via a "Knowledge" link in the canvas chat header.

Sections:
- **Priority order** — static text: System > Studio templates > Project instructions > Canvas instructions; within tier: Owner > Collaborator > Client.
- **Project instructions** — all `canvas_project_instructions` for the canvas, sorted pinned-first. Shows kind badge, pinned badge, timestamp.
- **Canvas instructions** — `canvas_messages` rows with `kind='instruction'`, newest first.
- **Templates applied** — `canvas_messages` rows with `template_id NOT NULL`, joined to `studio_prompt_templates.name`.
- **Tool cost** — accumulated `cost_cents` from `canvas_sessions` with a progress bar against the $5 cap.

## Canvas view registry (applied 2026-05-05 from change `add-canvas-multi-view`, Wave 1 only)

Wave 1 of the multi-view canvas effort shipped as a refactor with **zero user-visible change**:

- `packages/canvas/src/views/types.ts` exports `ViewId`, `ViewDefinition`, `CanvasViewContext`. Subpath export is `@rapoport-studio/canvas/views`.
- `apps/studio/src/app/(auth-walled)/(spec)/spec/[id]/views/registry.ts` holds `VIEW_REGISTRY` and `DEFAULT_VIEW`. Today the registry has one entry: `chat`.
- The three-column body that used to live inline in `idea-view.tsx` was extracted to `views/chat-view.tsx`. `idea-view.tsx` is now a host shell that selects a view from the registry.

Waves 2 (switcher UI + localStorage persistence) and 3 (Mirror viewer placeholder) were **deferred**: both ship zero user-visible value until a second view exists, and Wave 3 is more honestly its own change once Mirror has a meaningful number of perception fields to render. They are tracked in `openspec/ROADMAP.md` as follow-up changes (`add-canvas-view-switcher`, `add-canvas-mirror-view`).

The registry primitive is the contract: any future view ships by adding an entry to `VIEW_REGISTRY` and a component in `views/`. No further canvas-page edits required.

## Idea-capture view (applied 2026-05-08 from change `add-canvas-idea-capture`, RAP-58)

Second registered view — sticky-note board for free-form spatial capture distinct from the temporal chat surface. Pairs naturally with discovery interviews (Andrei / Gleb) where dozens of loose thoughts don't fit chat-turn cadence.

- `ViewId` widened to `"chat" | "idea-capture"`. Registry entry at `apps/studio/src/app/(auth-walled)/(spec)/spec/[id]/views/registry.ts` is `availability: () => true` — the surface is useful even on empty canvases.
- View body: `apps/studio/src/app/(auth-walled)/(spec)/spec/[id]/views/idea-capture-view.tsx` plus four sub-components in `views/idea-capture/` (`idea-board`, `idea-composer`, `cluster-filter`, `promote-cluster-dialog`).
- Reachable via **`?view=idea-capture` URL param** until `add-canvas-view-switcher` ships. The `page.tsx` reads `searchParams.view`, validates against the registry, and threads `initialView` into `idea-view.tsx`.

**Data model.** New table `canvas_ideas` (migration `20260508120000_add_canvas_ideas.sql`):

- Columns: `id`, `canvas_id` FK CASCADE → `canvas_sessions(id)`, `content` (1–2000 chars), `x` / `y` integer in `[0, 4096]`, `color` text in `('neutral','amber','rose','sage','blueprint','terracotta')`, `cluster_id` text NULL (free-form user-typed label, 1–80 chars, regex-blocked from markdown control chars), `created_at`, `updated_at`, `created_by` FK RESTRICT → `auth.users(id)`.
- Indexes: `canvas_ideas_canvas_idx`, partial `canvas_ideas_cluster_idx WHERE cluster_id IS NOT NULL`.
- `moddatetime` trigger on `updated_at`.

**RLS posture.** Single combined policy `canvas_ideas_access FOR ALL TO authenticated`, mirroring `canvas_files: owner or accepted invitee via canvas` — uses the **negative form `ci.role <> 'client'::text`** (established convention; client invitees get read-only access via the canvas page but cannot mutate the board). `WITH CHECK` adds `created_by = auth.uid()` to enforce author identity at insert.

**Color palette.** Six scoped tokens declared in `packages/ui/src/styles/globals.css` under a new `§ Idea-capture sticky tokens` block (`--idea-color-{neutral|amber|rose|sage|blueprint|terracotta}`). Five reuse existing OKLCH (persona-business / persona-ops / accent / primary / muted-foreground); only `rose` introduces a new value. Rationale: scoped tokens (not new global `--color-*`) avoid polluting the global palette with feature-specific names while still flowing through Tailwind 4 `@theme inline`.

**Server actions.** Four actions in `idea-actions.ts`: `createIdea`, `updateIdea` (handles cluster assignment via `clusterId` param — no separate `assignCluster`), `deleteIdea`, `promoteClusterToChat`. Lowercase snake error codes (`unauthorized | invalid_input | not_found | forbidden | db_error`) per the file-lifecycle-actions convention.

**Promote-to-chat — explicit non-Muse-tool decision.** `promoteClusterToChat` builds a Markdown body (`**Idea cluster: {id}**` heading, framing, `---`, bullet list of contents in `created_at ASC` order) and inserts it into `canvas_messages` via the existing composer path: same insert shape as `turn/route.ts` (role=`user`, kind=`message`, sequence from `nextMessageSequence`). **No new Muse tool, no system-prompt change.** Muse picks the cluster up via the regular turn loop. Out-of-scope rationale: a dedicated tool would force first-class status-pill rendering and Muse-side prompt logic for what is fundamentally a user-authored message.

**Drag protocol.** `@dnd-kit/core` `<DndContext>` in `idea-board.tsx`. Each card is a `useDraggable` source with the whole board as one `useDroppable` target. Local optimistic position state moves the card immediately on drag end; only `onDragEnd` commits a single `updateIdea({ x, y })` server call. Per-gesture commit (NOT per-move) is deliberate — single-write-per-gesture protects the action from being DDoS-ed by a long drag.

**Out of scope** (locks per archived proposal § Out of scope): real-time multi-user editing, AI-assisted clustering, lasso selection, FigJam/Miro export, the wizard-panel variant (separate change), voice input integration (RAP-51 lands separately), per-author visibility filters, idea-promote as a dedicated Muse tool, edit on archived canvases.

## Project routes (applied 2026-05-05 from change `add-project-home`)

`/projects` and `/projects/[slug]` are now v1 surfaces, scope-narrowed from the v2 vision (the v2 row was removed from the table above).

**`/projects`** (`apps/studio/src/app/(auth-walled)/(default)/projects/page.tsx`) — server page. Lists owner-scoped rows from `projects` ordered `updated_at DESC`. Each row links to `/projects/[slug]` and shows `name`, `slug`, `status`, plus a connection-presence meta line (`github · linear · 2h ago`) derived from `project_connections` via `loadConnectionKindsForProjects` (RAP-140). Platform owners see an inline `NewProjectForm` (slug + name + optional GitHub input — RAP-147) backed by `createProjectAction`; non-platform-owners see no form (gate matches dashboard / `/canvases`).

**`/projects/[slug]`** (`projects/[slug]/page.tsx`) — server page. Loads via `loadProjectBySlug(slug, viewerId)`; redirects to `/projects` when the row is missing or the viewer isn't the owner (RLS is the authoritative gate; the in-code check is for clean UX). Renders four surfaces in parallel:

1. **Header** — name, slug, status badge.
2. **Recent canvases** — `loadCanvasesByProjectId(projectId)`. Shows up to N canvases with title, slug, `StatusPill`, `updated_at`. Empty state when none.
3. **Connections card** — `<ConnectionsList>` client component (`apps/studio/src/components/projects/connections-list.tsx`, applied via `add-project-connections` RAP-140). Iterates `PROJECT_CONNECTION_KINDS` from `@rapoport-studio/connections` and dispatches each kind to its `panelRenderer.empty` (no row exists) or `panelRenderer.bound` (active row present). Server actions (`saveConnectionAction` / `removeConnectionAction` / `verifyConnectionAction`) live in `apps/studio/src/app/actions/project-connections.ts` and dispatch through the same registry — there are no per-kind branches in the action layer. v1 ships GitHub + Linear; new kinds plug in by dropping a TS file under `packages/connections/src/kinds/<slug>.tsx`. Replaces the legacy `<BindingsCard>` (closed via the same change). Disconnect uses an `<AlertDialog>` confirmation flow (`<BoundStateShell>` is the first AlertDialog consumer in the studio codebase).
4. **Knowledge card** — `loadProjectKnowledgeStats(projectId)`. Three tiles: instructions count, canvases-with-instructions count, most-recent-instruction timestamp. Aggregation runs in TypeScript over `canvas_project_instructions` rows joined to `canvas_sessions` (RLS owner policy on `canvas_sessions.user_id = auth.uid()` plus the `project_id` filter yields owner-scoped rows). Promote to RPC if rows grow past low thousands.

**Server action** — `createProjectAction` (in `app/actions/project-actions.ts`) gates on auth + `app_metadata.platform_owner`, validates slug via `isValidProjectSlug`, validates name via Zod (1–200 chars), inserts (`.insert(...).select("id").single()` so the post-insert connection row can FK off the project ID), maps Postgres `23505` to a friendly conflict message, calls `revalidatePath("/projects")`, then `redirect`s to `/projects/<slug>`.

**Creation-time GitHub** (RAP-147 / `add-project-connections` Phase 7). `<NewProjectForm>` accepts an optional `github_repo_input` field beneath the slug + name grid. On submit: project is **always** created. If the input parses cleanly via `PROJECT_CONNECTION_KINDS.github.parseInput`, a `project_connections` row is inserted in the same action with `status='active'` and `metadata=null` — verification is **lazy** and runs on the first `/projects/[slug]` render. Parse failure or DB-error inserting the connection: project survives; the form returns a soft warning ("Project created. GitHub input could not be parsed; bind it from the project page.") with an inline link to `/projects/<slug>` instead of redirecting. Empty / whitespace-only input behaves like no input (project created, no connection row, redirect fires).

**Deferred for v1**: Mirror status card and methodology card. Both require honest "as-of-deploy-bundle" timestamps that today's plain Cloudflare Workers build cannot expose without extra infra. Tracked as a follow-up Linear issue.

## Canvas file surface (applied 2026-05-05 from change `complete-canvas-file-lifecycle`)

> Deep contract for the **header strip** and **right rail** panes of the
> `/spec/[id]` page. Page-level composition lives at § Spec page surface.

Replaces the v1 three-tab artifact panel + openspec orientation tree with a per-canvas file-tree view backed by `canvas_files`. Every emitted file (proposal/design/tasks plus arbitrary Mirror, research, methodology paths) renders in folders inside the canvas itself; the chat-history `<ArtifactDiffBlock>` stream stays as in-flow context.

The whole canvas surface now reads and writes through `canvas_files` (RAP-44). The runtime no longer queries `canvas_artifacts` anywhere — Track F (RAP-75) drops the table after a 24h+ production bake.

### Surface

The auth-walled `/spec/[id]` page is composed of four panes laid out left-to-right inside `apps/studio/src/app/(auth-walled)/(spec)/spec/[id]/views/chat-view.tsx`:

1. `<CanvasHeaderStrip>` (`apps/studio/src/components/canvas/canvas-header-strip.tsx`) — a single-row H3 header across the canvas, ≤ 64 px tall (redesigned by change `redesign-canvas-chrome`, applied 2026-05-16). **Left — identity:** the canvas entity glyph (`Layers`, read from `getEntity("canvas")`) + the canvas title (with a ` · <branch>` suffix folded in for project-mode canvases — there is no standalone mode pill) + the slug as a mono small-caption. **Right — action cluster:** the lifecycle stepper (`<StageProgressView>`), conditional state chips (the `canvasStatus` chip renders only for `empty` / `archived`; the bridge-offline pill renders when Realtime is degraded), the icon-only action pills (`Discovery`, `Invite via Telegram`, `Save all pending (N)` when ≥ 2 pending, `Open PR` / `View PR`), a `⋯` overflow menu (`Invite teammate`, `Convert to project`, `Promote to spec` — each gated by its own predicate; the overflow trigger is hidden when no item would render), and the chat-width segmented toggle at the far right. Action pills are 32 × 32 icon-only squares carrying their label in `aria-label` + a tooltip. Non-owner viewers see identity + stepper + chat-width toggle, but no action pills and no overflow.
2. `<ChatColumn>` — chat (unchanged surface). Inline `<ArtifactDiffBlock>` per assistant message keeps the per-emission diff stream alive even though `canvas_files` itself has no version column; the synthesizer in `loaders.ts toArtifactRow` produces an `ArtifactRow` shape from each row's `is_pending` + session mode.
3. `<FileTree>` (`apps/studio/src/components/canvas/file-tree.tsx`) — folder tree built from `canvas_files.path` strings. Folders auto-collapse when leaf count > 5 at any depth; the parent chain of `selectedPath` always force-opens. Per-row indicators: pending dot when `is_pending=true`, `copy` lineage badge when `source_canvas_id` is non-null, muted styling when canvas mode is `legacy_promoted`. Per-row `[⋮]` menu surfaces `[Copy to other canvas…]` (suppressed in `legacy_promoted`).
4. `<FilePreviewPane>` (`apps/studio/src/components/canvas/file-preview-pane.tsx`) — mounts when a tree row is selected. Markdown preview / raw-text / diff toggle (diff visible only when `base_content` is non-null, i.e. project mode after conversion). Save control wired per row — see Save matrix below.

### Server actions

All under `apps/studio/.../spec/[id]/file-lifecycle-actions.ts` (RAP-73 Track C; live `2026-05-05`):

| Action | Effect | Mode gate |
|---|---|---|
| `convertToProjectAction(canvasId, repoFullName?)` | Resolves target repo (param → `project_connections.config->>'repo'` for kind=`github` on the canvas's parent project, RAP-140 → `STUDIO_DEFAULT_REPO_FULL_NAME`); creates `canvas/<slug>` branch; per-row `getFileContent('main', path)` + atomic `createTreeCommit`; SQL transaction flips `canvas_sessions.mode='project'` + sets `github_branch` / `github_repo_full_name` / `converted_to_project_at` / `base_sha_at_conversion` and writes `base_sha` / `base_content` / `is_pending=false` on every `canvas_files` row. | draft only |
| `saveFileAction(canvasId, path)` | Octokit `putFile` with optimistic concurrency on `base_sha`. On success refreshes the row's anchors. `ConflictError` (412) maps to `error='conflict'`. | project only |
| `saveAllPendingAction(canvasId)` | N=0 → no-op; N=1 → delegates to `saveFileAction`; N>1 → single `createTreeCommit` then re-anchor each row. | project only |
| `openPullRequestAction(canvasId, title?, body?)` | `createPullRequest` from `canvas/<slug>` → `main`. Idempotent on duplicates (GitHub 422 surfaces `alreadyOpen=true` with the existing or compare URL). Persists `last_pr_url`. | project only |
| `copyFileAction(targetCanvasId, sourceCanvasId, sourcePath, targetPath?)` | Cross-canvas via user-bound supabase (RLS-gated at both ends). Postgres 23505 → `error='path_collision'`. | any (RLS gates write target) |
| `listCopyTargetCanvasesAction(excludeCanvasId)` | Picker helper — RLS-filtered list of canvases the viewer can write to, excluding the source. Backs `<CopyToCanvasDialog>`. | viewer-scoped |

The legacy `saveArtifactAction` (`actions.ts`) remains the wired draft-mode save for the proposal/design/tasks triplet — it flips `is_pending=false` locally without a GitHub call. Project-mode saves go through `saveFileAction`. A generalised `saveCanvasFileAction` for arbitrary draft-mode paths (Mirror files, research) is deferred — those rows currently stay pending until the canvas converts.

### Save matrix (`<FilePreviewPane>` Save button)

| Canvas mode | Path | Handler |
|---|---|---|
| `legacy_promoted` | any | `null` (Save control hidden — frozen) |
| `draft` | `proposal.md` / `design.md` / `tasks.md` | `saveArtifactAction` (flips is_pending=false) |
| `draft` | other (Mirror, research, methodology) | `null` (disabled with "Save not available for this path yet" tooltip) |
| `project` | any | `saveFileAction` (Octokit putFile + anchor refresh) |

### Refresh strategy

Track C mutations (Convert / Save / Save-all / Open PR / per-file save in project mode) call `router.refresh()` on success so the page re-fetches mode + branch + PR URL + every `canvas_files` row's `base_sha`. This clears `selectedPath` as a side effect — acknowledged tradeoff for v1; a focused `getCanvasFilesAction` that lets chat-view do partial state updates is tracked as a follow-up.

### Retired surface

The pre-RAP-44 openspec orientation tree (`current/`, `changes/`, `archive/`, `_methodology/`, `mirror/` views built by reading `openspec/` from local fs) is gone. Removed modules: `file-panel-actions.ts`, `file-panel-fs.ts`, `file-panel-parsers.ts`, `file-panel-types.ts` (and their tests), `project-files-tree.tsx`, the in-tree `file-preview-pane.tsx`, plus the action exports `getProjectTree` / `getFilePreview` / `getReadContext` / `getAffectedOnPromote` / `getLinkedIssues` / `getLinkedCanvases`. Orientation use cases now live in chat — Muse's `read_openspec` / `read_archive` foundation tools cover them from the chat surface.

### Mirror runtime banner removed

The `not_available_in_runtime` banner introduced under § Mirror runtime in spec (2026-05-03) is removed. `write_mirror_field` migrated to a `mirrorWriter` callback (RAP-71); the studio backs the callback with a `canvas_files` UPSERT, so Mirror authoring works on Cloudflare Workers without the local-dev caveat. The `read_openspec` / `read_archive` bundle path is unchanged. The earlier known limitation in this file's § Mirror runtime in spec → Known limitations is **closed**.

### Promote action preserved

The original "Promote to /openspec/changes" action survives unchanged for triplet canvases that haven't converted. It keeps the legacy "copy three artifacts to disk + flip status to `promoted`" behavior. New canvases live their full lifecycle through Convert / Save / PR instead — Promote is residual until the seven `legacy_promoted` rows age out.

---

## User settings surface (applied 2026-05-05 from change `add-user-settings`)

First per-user preferences UI surface. Two consumer points and one writer surface, all backed by the new `public.user_settings` table (see `openspec/current/db/entities.md § User settings`).

### Canvas chat-width preset toggle

Added to `CanvasHeaderStrip` as a 3-position radio (`compact` / `normal` / `wide`) at the far-right end of the header action cluster. Always rendered, regardless of `canAct` — every viewer chooses their own reading width.

- Initial value comes from `loadUserSettings().layout.canvasChatWidth` server-side, threaded through `<CanvasView initialChatWidth>` into `<ChatView>`.
- Click handler is optimistic: state flips immediately, then `updateUserSettingsAction({ layout: { canvasChatWidth } })` fires. Toast on failure; otherwise silent.
- `<ChatView>` binds CSS variable `--chat-max-w` on its root from the current preset:
  - `compact` → `36rem`
  - `normal` → `48rem`
  - `wide` → `100%`
- `<ChatColumn>` outer section uses `max-width: var(--chat-max-w, 100%)` so the chat is bounded; a `<div className="hidden flex-1 lg:block" />` spacer between chat and file-tree fills remaining flex space, keeping the file tree right-anchored when chat is narrow.

The `100%` fallback in the CSS variable preserves the pre-RAP-77 visual for any consumer that mounts `<ChatColumn>` without setting the variable (legacy embedders, future Mirror view).

### Workspace preferences card on `/settings`

New `<WorkspacePreferencesCard>` rendered between `ApiKeyCard` and `AppearanceCard`. Single form, four fields:

| Field | Control | DB target |
|---|---|---|
| Default Muse persona | Tabs-as-radio (5 values) | `user_settings.workflow.defaultPersona` |
| Default canvas chat width | Tabs-as-radio (3 values) | `user_settings.layout.canvasChatWidth` |
| Default GitHub repo | Text input, `^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$` | `user_settings.workflow.defaultGithubRepo` |
| Locale | Select (auto / en / ru / ro) | `user_settings.appearance.locale` |

Submit dispatches `updateWorkspacePreferencesAction(prev, formData)` (in [`apps/studio/src/app/(auth-walled)/(default)/settings/actions.ts`](../../apps/studio/src/app/(auth-walled)/(default)/settings/actions.ts)) which validates each field and delegates to `updateUserSettingsAction(null, patch)`. Inner `unauthorized` / `invalid_patch` / `db_error` codes are mapped to user-friendly strings before the form re-renders.

The chat-width preset on this card and the chat-width toggle in the canvas header strip both write to the same `user_settings.layout.canvasChatWidth` slot and stay in sync via the next page render. There is intentionally no live cross-tab sync — `react-hooks/set-state-in-effect` discourages that pattern.

### `appearance.locale` consumer is deferred

The locale field is writeable but not yet consumed. `apps/studio` does not currently use `next-intl` (only `apps/web` does); wiring up Accept-Language override is a follow-up change. Until then the saved value is dormant — round-trips through the form and the DB but doesn't change rendered text.

### Display fields not migrated

`full_name`, `avatar_url`, `email_verified` stay in `auth.users.user_metadata` (Supabase Auth-managed). `anthropic_api_key` also stays in `user_metadata` as a special secret-shaped slot. The `add-user-settings` change deliberately does not pull display fields into the new table — see `openspec/archive/2026-05-05-add-user-settings/design.md § 8` for the rationale.

---

## Community member surface (applied 2026-05-06 from change `add-studio-self-serve-signup`)

Adds the `/community*` route family on `app.rapoport.studio` — the destination for community members signing up via the marketing-site CTAs (`BuildOnPlatform`, `CommunitySignal` on `rapoport.studio`). See `auth.md § Community member tier` for the access mechanism and `db/entities.md § Community member tier` for the backing tables.

### Routes

| Route | Group | File | Auth | Purpose |
|---|---|---|---|---|
| `/community/signup` | `(public)/community/signup/` | `page.tsx` + `actions.ts` | public (`publicRoutes` entry) | Magic-link signup form. `sendCommunityMagicLink` server action calls `signInWithMagicLink(email, "${siteUrl}/auth/callback?next=/community", { data: { signup_intent: "community" } })`. The `signup_intent` value is what the `bootstrap_community_member` DB trigger reads to flip the JWT flag — see `auth.md`. |
| `/community` | `(auth-walled)/community/` | `page.tsx` | community_member | Landing page. Lede + apply-to-certified affordance + sign-out. The dispatch target for `/auth/callback` when the JWT carries `community_member: true`. |
| `/community/profile` | `(auth-walled)/community/profile/` | `page.tsx` | community_member | Minimal profile view: display name, joined date, and discipline if the user has applied to certify. Held back from v1 polish — public-display opt-in is forward-compat in the schema (`community_members.display_opt_in`) but unsurfaced. |
| `/community/apply` | `(auth-walled)/community/apply/` | `page.tsx` | community_member | Apply-to-certified form. Fields: discipline, portfolio URLs, optional spec sample URL, short pitch, NDA + CoC opt-in checkboxes. POST goes to `/api/community/apply`. |

Auth on the three auth-walled routes is enforced at middleware via `AuthProxyConfig.communityRoutes` (passed by `apps/studio/src/middleware.ts` as `["/community", "/community/profile", "/community/apply"]`). A community member who hits any other `/community/*` path or any non-community route is treated as no-access — proxy signs them out and redirects to `/login?error=no_access`. A community member is also still gated by the existing `platform_owner` / `canvas_invitees` checks on every other route in the surface above (Architect mode `/spec/[id]`, dashboard, settings, etc.); the three tiers compose orthogonally, see `auth.md § Community member tier § Orthogonality`.

### API surface addition

| Route | File | Status | Auth | Purpose |
|---|---|---|---|---|
| `/api/community/apply` | `apps/studio/src/app/api/community/apply/route.ts` | v1, live | session (community_member) | POST handler for the apply-to-certified form. Validates with zod (mirroring the `/api/intake` shape but against the `certified_applications` table contract — see `db/entities.md § Community member tier`). Inserts into `db.certified_applications` via the user-bound supabase client (RLS gates write to `auth.uid() = user_id`). NDA + CoC timestamps are stamped server-side at insert. Optional upstream email forward shares the existing intake email infrastructure with a `kind: "certified_application"` discriminator. |

### Public auth-flow extension

`/auth/callback` (existing, owned by `auth.md`) gains a community-member dispatch branch: when the JWT carries `community_member: true` and the `next` query param is a `/community*` path, redirect to `${origin}${next}` (default `/community`). The route still does **not** mutate `app_metadata` or write to `community_members` — both are handled by the DB triggers documented in `auth.md § Community member tier`.

### Middleware wiring

`apps/studio/src/middleware.ts` updated:

1. `publicRoutes` gains `/community/signup`.
2. `createAuthProxy(...)` call gains `communityRoutes: ["/community", "/community/profile", "/community/apply"]`.

### i18n

All community-route copy ships under a `studio.community.*` namespace in `packages/i18n/messages/{en,ru,ro}` — same parity-test discipline as the rest of the studio. New strings render in all three locales from day one.

### Out of scope

The studio-side review workflow for `certified_applications` rows (accept / decline / shadow → mutate `outcome` + write `reviewed_by` / `reviewed_at` / `notes`) is deliberately not surfaced in v1. Reviews happen out-of-band (email / Linear ticket); a future change will ship an admin surface inside `apps/studio`.

---

## Canvas conversation lifecycle FSM (applied 2026-05-07 from change `add-canvas-state-machine`)

The canvas chat surface coordinates a non-trivial conversation lifecycle (turn dispatch, SSE consumption, tool-status pills, error surface, optimistic placeholder) that previously lived as four independently-mutating React atoms (`pending`, `error`, `placeholder`, `activeTools`) plus a hand-rolled SSE consumer inside `runTurn` in `chat-view.tsx`. RAP-78 replaced that mosaic with a single typed XState v5 actor.

### Where it lives

`apps/studio/src/app/(auth-walled)/(spec)/spec/[id]/canvas-machine.ts` — single-region FSM, route-colocated next to the canvas page. `canvas-machine.test.ts` co-located. Consumed by `views/chat-view.tsx` via `useActorRef` / `useSelector` from `@xstate/react`.

### Dependencies

`xstate@^5.31.0` and `@xstate/react@^5.0.5` are listed as `dependencies` in `apps/studio/package.json`. No `@statelyai/agent` (rejected — see archived `add-canvas-state-machine/design.md § Delta 1`).

### Shape

- **Context** — `{ canvasId, error, placeholder, activeTools: { pills, startCounts }, lastResult }`. `lastResult` is the load-bearing carrier between `streaming` and `settling`; the React layer reads it during the `SETTLED` handshake and applies it to `useState`-backed message/artifact data.
- **Events** — typed discriminated union: `TURN_REQUEST`, `TOOL_STARTING`, `TOOL_COMPLETED`, `TURN_COMPLETE`, `TURN_RESPONSE_ERROR`, `STREAM_ERROR`, `SETTLED`, `DISMISS_ERROR`. All event creation happens inside the SSE callback actor or the React layer; no untyped sends.
- **States** — `idle → sending → streaming → settling → idle` happy path. `error` reachable from `sending` or `streaming` on `STREAM_ERROR` / `TURN_RESPONSE_ERROR` / `TURN_COMPLETE` with `final.ok === false` (guard `isResultOk`). `error → idle` on `DISMISS_ERROR`; `error → sending` on `TURN_REQUEST` (clears prior error via the same `clearLifecycle` action used in the happy path).
- **Atomicity** — `clearLifecycle` is the single action that resets all four atoms at the start of every turn. Concurrent re-renders cannot observe partial state (e.g. cleared error + stale pills).

### SSE actor

`turnSseStream` is a `fromCallback` actor invoked by the `sending` state. It owns:

- The `fetch` to `/spec/[id]/turn` (the SSE route, unchanged at the wire format).
- An `AbortController` returned via the cleanup function — xstate calls it when the parent state exits, which cancels the fetch on unmount or mid-turn navigation.
- Consumption of `parseSseStream` from [turn-stream-protocol.ts](../../apps/studio/src/app/(auth-walled)/(spec)/spec/[id]/turn-stream-protocol.ts) — that helper is unchanged from pre-RAP-78. The actor sends back typed `TOOL_STARTING` / `TOOL_COMPLETED` / `TURN_COMPLETE` events.
- Decoding of non-OK / non-streaming responses (auth / bad-request) into `TURN_RESPONSE_ERROR`.

The actor never holds React state, never imports from `@xstate/react`. Pure I/O over the SSE protocol.

### React integration — the `SETTLED` handshake

`messages`, `artifacts`, `artifactsByMessage`, `files`, `status`, `inviteOpen`, `templateModalOpen`, `selectedPath`, `copySourcePath`, `promoteState`, `savingPath` all stay as `useState` in `chat-view.tsx`. Only the four lifecycle atoms moved into the machine.

The bridge is the `SETTLED` handshake:

1. `TURN_COMPLETE` carries `result: SendMessageResult` into the `settling` state via `captureResult`.
2. A `useEffect` keyed on `state.matches("settling") && context.lastResult !== null` reads the result, applies it via existing `setMessages` / `setArtifacts` / `setStatus` / `integrateEmittedArtifacts` callbacks, then sends `{ type: "SETTLED" }` back to the actor.
3. The actor transitions `settling → idle` and clears `lastResult`.

This decouples the actor from React data flow — the machine never fires React setters directly, which keeps Strict Mode double-invocation guarantees intact.

`pending` boolean derives from `state.matches("sending") || state.matches("streaming") || state.matches("settling")`, replacing the previous `useTransition` pending tuple. Behavior preserved for non-Suspense surfaces (canvas page is not under Suspense).

### Setup typing

The machine uses `setup({ types: { context, events, input } }).createMachine(...)` — xstate v5's typed entry point. Direct calls to `createMachine` with untyped objects are not used. Guards (`isResultOk`) and named actions (`clearLifecycle`, `closePlaceholder`, `appendToolStarting`, `flipToolCompleted`, `setError`, `setErrorFromResult`, `captureResult`) are declared inside `setup` and referenced by string id from state config — gives compile-time event narrowing without `@xstate/cli` typegen.

### Persistence

In-memory only. The machine resets on page mount; suspend / resume is reconstructed from `canvas_sessions` server-derived fields and the existing `loadCanvasState` flow. No localStorage. Aligns with the studio's "DB is source of truth, status is derived not stored" persistence philosophy (`muse.md § Persistence`).

### What this change did NOT touch

- `/spec/[id]/turn` SSE route — same payload, same wire format.
- `runMuseTurn` / `runMuseWithTools` — server-side LLM call unchanged.
- `buildSystemPrompt` — unchanged.
- All other `useState` in `chat-view.tsx` — left intact.
- DB schema, RLS, auth — no changes.
- Bundle cost: ~19 KB gzipped for `xstate` + `@xstate/react`. One-time addition.

### Position in the broader xstate roadmap

This change is the **foundation** for `add-xstate-state-orchestration` (the next change), which:

- Extracts reusable primitives (`serverActionActor`, `supabaseRealtimeBridge`, `makePersistedActor`, `attachInspector`, `setup` re-exports) from the apps-local pattern into a new `@rapoport-studio/state` package.
- Adds a second consumer (`promptMachine` on `/templates/[id]`) — the second consumer is the threshold that earns the `packages/state` promotion per the studio's earn-it-not-anticipate-it rule.
- Wave 2 expands `ideaMachine` into a parent orchestrator with five child actors (`chat`, `files`, `instructions`, `participants`, `costBudget`) and a single `supabaseRealtimeBridge` actor that replaces per-component Realtime subscriptions, gaining reconnect-resilience as a structural property.

The `chat.machine.ts` child in Wave 2 will subsume the FSM described above. Until then, this single-region machine is the canvas surface's authoritative lifecycle source.

---

## Task page (applied 2026-05-07 from change `add-forge-ui-task-detail`)

A new top-level surface lives at `/tasks/[linear-key]` — the studio's first Forge-execution view. The route is platform-owner-only; non-owners redirect to `/`. Linear key is the natural identifier (uppercase-normalized server-side); resolution failure renders a sibling `not-found.tsx`.

### Route group

```
apps/studio/src/app/
└── (auth-walled)/
    └── (forge)/
        ├── layout.tsx                  ← server-side platform_owner gate
        └── tasks/
            └── [key]/
                ├── page.tsx            ← server component, composes the Task
                ├── not-found.tsx       ← rendered when loadTask resolves nothing
                ├── stream/
                │   └── route.ts        ← SSE handler (see `forge/entities.md § SSE streaming`)
                └── forge-actions.ts    ← 15 server actions
```

Route group `(forge)` is paren-wrapped — groups Forge routes under a single layout without prefixing the URL. Future `/forge` dashboard or `/forge/runs` go in the same group without URL coupling.

### Composition

```
<TaskHeaderStrip>             ← Linear key + title + Linear ↗ + run-status pill
<RunActionsBar>               ← Start / Cancel / Retry, cost meter, model badge
<Suspense>
  <TaskBody>
    <PhasePipeline>           ← 4 phases, accordion
      Phase 1 — <Phase1PlanBody>     (Markdown + Approve/Iterate/Improve)
      Phase 2 — <Phase2ImplementBody> → `<LiveBuilderPanel>` (Builder observability — checkpoints, cost vs cap, worktree path, tool-call tail, branch/PR link; SSE via `useForgeRunStream`)
      Phase 3 — <Phase3ReviewBody>   (FindingRow tree + Address all / Accept)
      Phase 4 — <Phase4PrBody>       (PR card + Merge / Request changes)
    <PropertiesSidebar>       ← right column, 180px, mode-aware
```

### Phase 2 — `<LiveBuilderPanel>` (applied 2026-05-09 from change `add-forge-builder-orchestrator-ui`)

`<Phase2ImplementBody>` is a client component that merges SSR `forge_*` rows with the latest SSE snapshot from `/tasks/[key]/stream?run=<runId>` (`useForgeRunStream` + `mergeForgeTaskStreamSnapshot`). `<LiveBuilderPanel>` is presentational:

1. **Checkpoint dots** — five segments aligned to Builder checkpoint taxonomy (aggregate status from the implement step).
2. **Cost accumulator** — running USD vs `cost_cap_usd` with warn band at 80% (`forge/commands.md § Cost guard`).
3. **Worktree path** — monospace line from `forge_runs.worktree_path`.
4. **Tool-calls tail** — last 10 entries from `forge_steps.tool_calls` (implement phase).
5. **Branch / PR link** — `head_branch` plus `forge_outputs` row with `kind='pr'` when present.

**Empty state:** when the implement step is still `pending` (no Builder run for this task), the body shows copy-paste instructions: `pnpm forge build <linear-key>` — there is no in-UI "Start build" in v1 (`add-forge-build-from-ui`).

Default expansion: Canvas embedded but collapsed (Task exists *because* there is Forge work to monitor); Forge expanded. If `task.forgeRun` is null, Forge shows an empty state with the Start CTA in `<RunActionsBar>` lit.

### `loadTask` projection

```ts
loadTask(linearKey, supabase): TaskView
  ├─ linearIssue       — fetched live from Linear GraphQL via LinearClient
  ├─ canvasSession?    — SELECT FROM canvas_sessions WHERE linear_issue_key = $1 LIMIT 1
  └─ forgeRun?         — SELECT FROM forge_runs ... ORDER BY created_at DESC LIMIT 1
```

Pure read; no writes happen against a "Task". Writes go to `forge_runs`, `canvas_sessions`, or Linear directly.

### Properties sidebar

Right-rail 180px column, `var(--bg-1)` mode-aware background. Rows: Status, Priority, Assignee, Labels, Branch, Base SHA, Cost, Model. Each row is mono label + plain value; missing values render as a dim em-dash. Hidden below `lg:` breakpoint — desktop-only at ≥1024px.

### Mode rail

The studio rail (`apps/studio/src/components/studio-rail.tsx`) gains an **F** glyph item active for routes matching `/tasks` (the index), `/tasks/*` (detail pages), or `/forge/*`. Letter-glyph (per `design-system.md § Letter-glyph system`) — the rail uses single-letter glyphs (D/C/P/I/F/T/S) for orthogonality with Lucide icons reserved for entity-level surfaces (per `entities/README.md § Iconography contract`). The rail item links to `/tasks` (the recent-runs index — see § Task discovery).

### Auto-advance

Phases auto-advance through done-states by default. Auto-advance is blocked on:

- Phase 1 — always (every plan needs Pavel review).
- Phase 3 — if any `critical` finding exists.
- Phase 4 — never auto-advances past `done` (Pavel always reviews PR before merge).

The toggle lives on `forge_runs.auto_advance` (boolean, default `true`). Per-run override happens via Settings (modal UI deferred — direct DB UPDATE acceptable in v1).

### Failed states

`<PhasePipeline>` accepts `status: 'failed'` and renders a red-tinted phase header with `failed_reason` in the meta line. Rich failure UX (per-test-failure tree, partial-commit recovery, intelligent retry suggestions) is deferred to `add-forge-failed-states`.

### Cross-references

- Backing schema — `db/entities.md § Forge tables`.
- Persisted entities — `entities/persisted.md § Forge entities`.
- Server runtime + auto-comments — `forge/entities.md § UI surface`.
- UI primitives consumed (`<PhasePipeline>`, `<LiveLogStream>`, `<FindingRow>`) — `ui.md § Forge primitives`.
- Severity tokens consumed by findings UI — `design-system.md § Severity tokens`.

### Task discovery (applied 2026-05-08 from change `add-forge-discovery-surface`)

`/tasks/[key]` is reached three ways. All three surfaces are read-only; run lifecycle actions (start, cancel, retry, address findings) live on the `[key]` detail page only. RLS on `forge_runs` (`platform_owner` only — see `db/entities.md § Forge tables`) plus the `(forge)/layout.tsx` redirect both enforce the same boundary at the data layer and the route layer respectively.

1. **F rail** at `apps/studio/src/components/studio-rail.tsx` — top-level navigation entry, links to `/tasks` (the index). Active for `/tasks`, `/tasks/*`, and legacy `/forge/*` paths.
2. **`/tasks` index** at `apps/studio/src/app/(auth-walled)/(forge)/tasks/page.tsx` — recent 50 `forge_runs` rows across all projects, ordered `created_at DESC`. Each row is `<EntityCard variant="row">` with the `forge` entity icon, Linear key as name, command as identifier, started_at relative time + cost in the meta line, and a status pill (`<ForgeRunStatusPill>`) covering the six-state taxonomy (`queued`/`running`/`awaiting_input`/`done`/`failed`/`cancelled`). Empty state: "No Forge runs yet. Start one from a project page." No filters or search in v1 — single-screen 50-row list.
3. **Project Forge runs card** at `<ForgeRunsCard>` on `/projects/[slug]` — 5 most recent runs scoped to the project, with two empty-state branches keyed on whether the project has a known Linear-issue prefix (`studio → RAP`, `unbind → UNB`). Projects with a prefix render an "Open a Linear issue with a Plan to start one" hint; projects without ("vivod", "dentour" today) render "This project has no Forge channel yet" with the footer link hidden. The footer "View all →" link targets `/tasks?project=<slug>` — a stable forward-reference; the index ignores the `project` search param in v1 (filter execution lands when usage signal demands it).

The status taxonomy is single-sourced in `apps/studio/src/app/(auth-walled)/(forge)/tasks/loaders.ts` as `FORGE_RUN_STATUS_DISPLAY` and consumed by both `<ForgeRunStatusPill>` (the six-status pill primitive in `apps/studio/src/components/forge/`) and the project card. If `forge/entities.md § Run lifecycle states` ever changes the taxonomy, the mapping moves with it.

**v1 deviation — project columns deferred.** Neither the `/tasks` row nor the project card carries `projectSlug` / `projectName` columns. `forge_runs` has no `project_id` and `projects` has no `linear_prefix`; the project card's scoping uses `linear_issue_key LIKE 'PREFIX-%'` against a static slug→prefix map mirroring `forge.config.mjs § projects` (which is CLI-only and can't be bundled into Next.js). When a third project goes live or the static map starts feeling like tech debt, a follow-up migration adds `forge_runs.project_id` (or `projects.linear_prefix`) and the loaders join it directly.

### Task surface v2 (applied 2026-05-11 from change `redesign-tasks-surface`)

Six-wave redesign that promotes `/tasks` from a thin list-router into a Linear-grade working list. Detail page `/tasks/[key]` preserves byte-compat for existing Slack permalinks and Linear comments — route segment, props, loader, and component tree all unchanged; only typography / spacing / chip styles updated for visual parity with the inline-expanded panel.

- **Collapsed row** (`apps/studio/src/components/forge/task-list-row.tsx`) — two-line CSS grid (`auto auto auto 1fr auto auto`, gap 14px). Row 1: caret · command icon · linear key as real `<Link>` · title · `<StatusPill>` · more-dot. Row 2: chips (type + `+N` overflow + priority) · `<MiniPhasePipeline>` · meta `{model} · {middle-truncated branch}[· PR #{n}]` · 12px assignee monogram · `{age} ago` · `${cost} / ${cap}`. Branch middle-truncates at 34 chars (head 18 / tail 12). Linear key element is `<a href="/tasks/{key}">` so cmd-click / right-click reach the detail page. Keyboard contract: Enter/Space toggle, Esc collapse, ↑/↓ roving tabindex.
- **MiniPhasePipeline** (`mini-phase-pipeline.tsx`) — four-dot inline phase indicator with six glyph states (`done` / `running` / `pending` / `failed` / `queued` / `skipped`) plus awaiting-input warning-triangle overlay. CSS-only glyphs (no SVG imports). `aria-label` enumerates phase states in words ("Plan: done, Implement: running, Review: pending, PR: pending").
- **Inline expansion** (`task-list-row-expanded.tsx`) — clicking a row mounts the existing `<Phase1PlanBody>`…`<Phase4PrBody>` shells inside the row; the list never unmounts. URL `?expanded=KEY` makes the state shareable, refresh-safe, and back-button-friendly. Single-stream guarantee: only one row's expanded panel is mounted at a time → only one Phase-body SSE stream live, regardless of toggling sequence.
- **PropertiesStrip** (`properties-strip.tsx`) — horizontal 8-column `<dl>` (STATUS · PRIORITY · ASSIGNEE · LABELS · BRANCH · BASE SHA · COST · MODEL) shown inside the inline-expanded panel. Shares the `<PropertyCell>` primitive with `<PropertiesSidebar>` on the detail page (the same data, two orientations).
- **Filter strip** (`task-list-filters.tsx`) — segmented `all · running · awaiting · done · failed` + `project: all ▾` + `command: all ▾` dropdowns + `sort: recent` label. State lives in URL params (`?status=`, `?project=`, `?command=`) written via `router.push` so the back button rewinds filters. Auto-clear-on-expand banner: when `?expanded=KEY` lands on a filtered-out row, `history.replaceState` silently clears the offending filter and a banner offers "restore filter". No native `<select>`; portalled menus with `<button>` triggers.
- **Display constants** (`apps/studio/src/app/(auth-walled)/(forge)/tasks/display-constants.ts`) — client-safe split of `FORGE_RUN_STATUS_DISPLAY` (now with a `color` field) and the new `PHASE_DOT_DISPLAY`. The split is structural: `loaders.ts` carries `import "server-only"`, so client components import display tokens from this file. Status color is `var(--color-${token})`; the six tokens (`--color-done` / `--color-running` / `--color-failed` / `--color-queued` / `--color-awaiting` / `--color-fg-dim`) live in `packages/ui/src/styles/globals.css`.
- **Loader shape** (`loaders.ts`) — `loadRecentForgeRuns(supabase, limit)` returns `LoadRecentForgeRunsResult = { runs: ForgeRunRowEnriched[]; linearError: boolean }`. The runs are joined to Linear issue metadata (title / priority / assignee / labels) via `LinearClient.fetchIssuesBatch` (see `forge/entities.md § Linear API integration`). `linearError: true` surfaces a "Linear sync paused — showing run data only" banner on the page; the Supabase rows still render with monogram fallbacks.

Boundaries: no new package deps, no DB schema changes, no RLS / auth changes. Stays inside `apps/studio` + the existing `packages/forge/src/core/` carve-out for `LinearClient.fetchIssuesBatch`. `StatusPill`/`MonoLabel` are inlined in `task-list-row.tsx` for now; promote to `@rapoport-studio/ui` only when a third consumer appears.

---

## Canvas lifecycle stages (applied 2026-05-08 from changes `add-canvas-stage-primitive` Wave 1 + `add-canvas-progress-view`)

> Deep contract for the **stepper** element of the `/spec/[id]` header
> strip. Page-level composition lives at § Spec page surface; resting-state
> contract for the stepper is named in D-SP-7.

A canvas now carries a deterministic lifecycle stage and a 0–100 completeness percent, computed from observable signals (Mirror perception fields, OpenSpec triplet section presence, GFM footnote count, tasks-file wave structure, Linear-issue link). The stage is orthogonal to the existing `canvas_sessions.status` (`empty | drafting | promoted | archived`) and `mode` (`draft | project | legacy_promoted`) — both stay live and untouched.

Wave 1 ships **only the primitive** — schema, computation engine, lifecycle YAML, and Realtime broadcast. The progress UI, risk register, research catalog mapping, sign-off flow, and analytics are all explicitly out of scope; each is a separate follow-up change (see § Track E follow-ups below).

### 6 stages

| # | Stage | % range | Primary owner | Persona hint | What signals "done" |
|---|---|---|---|---|---|
| 0 | `intake` | 0–10 | platform_owner | architect | Lead accepted → canvas created → first user / client message |
| 1 | `discovery` | 10–30 | owner | domain | Mirror perception populated (≥5 client_vocabulary, ≥3 key_screens, body sections non-empty) |
| 2 | `concept` | 30–50 | owner | business | proposal.md sections all non-empty (Why pressures ≥3 / What changes / Out of scope ≥2 / Impact / Acceptance ≥3) + Linear link |
| 3 | `design` | 50–70 | owner | tech | design.md ≥3 deltas; ≥1 footnote per delta; boundaries section + ≥3 failure-mode rows |
| 4 | `tasks` | 70–90 | owner | architect | tasks.md ≥1 wave; ≥3 task items per wave; verification regex per wave; out-of-scope + archive sections |
| 5 | `build_ready` | 90–100 | platform_owner | architect | Prior stages ≥80% pass; sign-off recorded; convert / promote action gate satisfied |

Range membership is half-open `[start, end)` with the 100% boundary bound to `build_ready`. Per-stage check weights sum to the stage's range delta (10 / 20 / 20 / 20 / 20 / 10) and total exactly 100 across all stages — verified by the `LifecycleSchema` Zod refinement.

Non-linear progression is allowed: a canvas can move backward (delete a vocabulary entry → drops from 32% to 28%). The stored `stage` tracks current position, not high-water mark. Phase 2 progress UI surfaces regressions as a warning.

### Lifecycle YAML

`openspec/_methodology/lifecycle.yaml` is the authoritative source for the 6 stages, 35 checks, per-check `kind` / parameters, and check weights. Hand-edited; not generated. Bundled into the studio worker via `LIFECYCLE_YAML` exported from `apps/studio/src/generated/openspec-bundle.ts` so Cloudflare Workers can parse it with no `node:fs/promises` dependency. Drift-guard `node tools/build-openspec-bundle.mjs --check` enforces regeneration after edits.

Per-project overrides live in `openspec/_methodology/projects/<slug>.yaml` under an optional `stages:` section. Three override types:

- `check_weights` — adjust individual check weights; per-stage sum must still equal the stage's range delta.
- `optional_researches` — additional research kinds suggested at this stage (Phase 3 consumes).
- `custom_researches` — project-scoped research kinds with `defined_in` paths under `_methodology/projects/<slug>/researches/` (Phase 5 consumes; Phase 1 only validates structure).

A malformed override file is caught by the recompute action and falls back to base lifecycle. Override values never leak into the resolved Muse system-prompt context — `composeResolved` passes `stages` through unmerged.

### `recomputeStageAction(canvasId, triggeredByAction)`

```
apps/studio/src/app/actions/stage-actions.ts

recomputeStageAction(canvasId, triggeredByAction): Promise<StageRecomputeResult>
  → loadCanvasSnapshot(canvasId)        ← 5 parallel queries
  → loadLifecycle()                     ← reads bundled LIFECYCLE_YAML, cached
  → loadProjectStageOverrides(slug)?    ← null when project YAML has no `stages:`
  → mergeStageConfig(lifecycle, overrides)
  → runAllChecks(snapshot, mergedConfig)
  → percent = sum(check.weight × passed)
  → stage   = stageForPercent(percent)
  → if stage / percent changed:
      UPDATE canvas_sessions SET stage, stage_percent
      INSERT canvas_stage_events(prev_stage, next_stage, prev_percent, next_percent, triggered_by_action)
  → returns { stage, percent, checks: { passed[], failed[] }, transitioned }
```

**Failure-isolated by contract.** A top-level `try/catch` returns a default `{ stage: 'intake', percent: 0, transitioned: false }` on any internal throw. Auto-recompute hooks rely on this — stage drift is acceptable; a broken parent action (turn / save / promote) is not.

### Auto-recompute hooks

The action fires from six upstream entry points, every time canvas state could change:

| Entry point | File | Action name |
|---|---|---|
| Streaming turn route | `apps/studio/.../spec/[id]/turn/route.ts` after `turn_complete` | `send_message` |
| Save artifact (draft) | `apps/studio/.../spec/[id]/actions.ts` | `save_artifact` |
| Add project instruction | `apps/studio/.../spec/[id]/actions.ts` | `add_project_instruction` |
| Save file (project mode) | `apps/studio/.../spec/[id]/file-lifecycle-actions.ts` | `save_file` |
| Convert to project | `apps/studio/.../spec/[id]/file-lifecycle-actions.ts` | `convert_to_project` |
| Mirror writer | `apps/studio/.../spec/[id]/tool-use-context.ts` | `mirror_write` |

The Mirror writer hook is `queueMicrotask`-deferred so recompute does not extend tool execution latency.

The streaming turn route is the canonical "user message arrived" entry point — design.md Delta 5 originally referenced a `sendMessageAction` server action that was removed in `add-tool-status-streaming-ui`; the action name reported on `canvas_stage_events.triggered_by_action` is still `send_message` to preserve the design's audit-trail vocabulary.

### Realtime broadcast

Every stage / percent change inserts a row into `public.canvas_stage_events` (see `db/entities.md § Canvas lifecycle stage columns`). The Phase 2 progress view (below) subscribes via Supabase Realtime; Wave 1 guarantees the event fires correctly.

### Progress view

The Phase 2 consumer surfaces the stage / percent / check tree in the canvas header. Composition:

| Surface | File | Shape |
|---|---|---|
| `loadCanvasStageAction(canvasId)` | `apps/studio/src/app/actions/stage-actions.ts` | Read-only server action; reuses `loadCanvasSnapshot` + `loadLifecycle` + `runAllChecks` and reads the latest `canvas_stage_events` row. Returns `CanvasStageView`. Top-level try/catch returns the sentinel `{ stage: 'intake', stagePercent: 0, ok: false, ... }` on any throw — never throws. |
| `useCanvasStage(canvasId, initial)` | `apps/studio/src/hooks/use-canvas-stage.ts` | Client hook. Seeds state with the SSR view, opens a Supabase Realtime channel filtered by `canvas_id` on `canvas_stage_events`, and on each tick refetches via `loadCanvasStageAction` (debounced 250 ms). Subscribe failure or missing browser env vars flip `isLive=false` without throwing. Returns `{ view, isLoading, isLive, lastUpdatedAt }`. |
| `<StageBadge>` | `apps/studio/src/components/canvas/stage-badge.tsx` | Compact `<SmartPill>` rendering `Concept · 38%`. Renders muted "—" placeholder when `view.ok=false`. `aria-expanded` reflects the popover state. |
| `<StageProgressView>` | `apps/studio/src/components/canvas/stage-panel.tsx` | Composite badge + popover. Panel layout: stage header (`Concept · 30–50%`), progress bar, `Updated 12s ago • via save_artifact` meta, two `<Collapsible>` sections (passed checks closed by default, failed checks open by default), `not live` hint when Realtime is degraded. |

`<CanvasHeaderStrip>` accepts `stageView: CanvasStageView | null` and `isStageLive: boolean` and renders `<StageProgressView>` as the leading element of the header's action cluster (change `redesign-canvas-chrome` dropped the standalone mode pill). The `useCanvasStage` call lives inside `<ChatView>` (already a client component) and threads the result down.

The `CanvasStageView` shape:

```typescript
type CanvasStageView = {
  stage: StageName;
  stagePercent: number;
  stageRange: { start: number; end: number };
  primaryOwner: 'platform_owner' | 'owner';
  personaHint: 'architect' | 'domain' | 'tech' | 'business' | 'ops';
  checks: {
    passed: { id: string; weight: number }[];
    failed: { id: string; weight: number; hint?: string }[];
  };
  lastEvent: {
    prevStage: StageName;
    nextStage: StageName;
    prevPercent: number;
    nextPercent: number;
    triggeredByAction: string;
    createdAt: string;
  } | null;
  ok: boolean;  // false when degraded to sentinel
};
```

**Persisted vs computed.** `loadCanvasStageAction` runs the same computation as `recomputeStageAction` minus the persistence side-effects, and additionally reads the most recent `canvas_stage_events` row. If the persisted `canvas_sessions.stage` disagrees with the freshly-computed stage, the persisted value wins for badge display (so two participants viewing the same canvas see the same badge) and the discrepancy is logged — a discrepancy means an upstream auto-recompute hook is missing on a recent mutation.

**Realtime payload is a "something changed" signal.** The audit row only carries transition deltas (prev/next stage, prev/next percent, triggered_by_action), not the per-check outcomes the panel needs. Keeping the event payload thin avoids embedding 35 check results per broadcast; the hook always refetches the full check tree via `loadCanvasStageAction` after each tick.

**No view-registry entry.** The progress surface is an overlay on the chat view, not a competing view. A full-screen progress view would land as a separate change against the multi-view registry if it ever becomes valuable.

**Supporting primitives shipped alongside:**

- `<Popover>` in `packages/ui/src/components/popover.tsx` — radix-ui-backed; mirrors the dropdown-menu surface tokens (`bg-popover`, `text-popover-foreground`, `shadow-md`, the standard slide-in animation). Reusable for the Phase 4 risk register summary and the Phase 6 sign-off tray.
- `getBrowserSupabase()` in `apps/studio/src/lib/supabase-browser.ts` — memoised browser client factory consolidating the inline `createBrowserClient` call from `login-form.tsx`. Future Realtime consumers (file-tree subscription, etc.) reuse the single WebSocket.
- `timeAgo()` in `apps/studio/src/lib/time-ago.ts` — `Intl.RelativeTimeFormat` wrapper, no extra dependency.

### Evaluator implementation note

The recompute engine ships **14 `kind`-keyed evaluators** in `apps/studio/src/lib/stage-checks/evaluators.ts`, dispatched by `runAllChecks`. Each evaluator is parameterised by the YAML row (path / regex / min / section / etc.); the YAML stays the single source of truth for weights and parameters. Per-id check coverage is achieved through synthetic-snapshot tests against `runAllChecks` rather than per-id files.

The kinds are: `db_query`, `db_field_non_null`, `canvas_files_path_exists`, `mirror_field_min_count`, `mirror_field_or_body_min`, `mirror_body_non_empty`, `regex_count_min`, `regex_present`, `regex_section_non_empty`, `footnotes_per_section_min`, `tasks_per_wave_min`, `regex_per_wave`, `derived_previous_stages`, `derived_action_available`. Adding a new kind is a two-step change: extend `CheckSchema` in `packages/muse/src/templates/lifecycle-schema.ts` and add a matching `dispatch` branch.

### Track E follow-ups (deferred)

Phase 2 shipped via `add-canvas-progress-view` (see § Progress view above). Phase 3–7 each ship as their own OpenSpec change:

| Phase | Change slug | Scope | Status |
|---|---|---|---|
| 2 | `add-canvas-progress-view` | Top bar + stage panel + check list; Realtime subscription consumer | Shipped 2026-05-08 |
| 3 | `add-stage-research-mapping` | Per-stage research catalog, `[Run]` / `[Assign to]` buttons (depends on Track C) | Deferred |
| 4 | `add-stage-risk-register` | Risk detector functions + risk panel + mitigation hints | Deferred |
| 5 | `add-stage-custom-researches` | User-defined per-stage research kinds (depends on Track C Phase 6) | Deferred |
| 6 | `add-stage-signoff-flow` | Manual sign-off button + force-promote risk detection; populates `signoff_at` / `signoff_by_user_id` | Deferred |
| 7 | `add-stage-analytics` | Aggregations: avg time per stage, stuck-canvas detection, risk frequency | Deferred |

Backward-incompatible removal of `canvas_sessions.status` (if redundant with `stage`) is contingent on Phase 7 analytics findings.

### Cross-references

- Backing schema — `db/entities.md § Canvas lifecycle stage columns`.
- Authoritative lifecycle config — `openspec/_methodology/lifecycle.yaml`.
- Per-project override grammar — `openspec/_methodology/projects/<slug>.yaml § stages`.
- Resolved Muse context pass-through — `packages/muse/src/templates/schema.ts § ResolvedProjectContext.stages`.

---

## Canvas action surface contract (applied 2026-05-15 from change `move-canvas-top-panels-to-dialogs`)

Every canvas-scoped action that mutates non-chat state — intake (Discovery), invites (Bind Telegram, canvas-level invites), lifecycle (Convert to project, Save all pending, open PR), stage navigation (Stage rail) — renders as a **pill in `<CanvasHeaderStrip>`** that opens a **URL-addressable dialog** registered in `apps/studio/src/overlay/registry.ts`. Pill visibility is a pure function of `(stageView, mode, ownerCaps, pendingCount)`. The canvas page body below the header strip renders **only** the chat / view surface — no inline action cards, no inline panels, no inline forms.

Exceptions require an explicit justification recorded in the change that introduces them: e.g. an action whose render is part of the artifact itself (a chat-thread reply form) is part of the view, not the action surface.

This rule supersedes any earlier wording that allowed inline panels above the canvas view dispatcher; the page body composition is now header-strip-driven, top to bottom.

**Refined 2026-05-16 by change `redesign-canvas-chrome`.** The contract above still holds — every canvas-scoped action lives in `<CanvasHeaderStrip>`, the page body stays chat/view-only — but the literal "renders as a **pill**" wording is now one of two header shapes: (a) a 32 × 32 **icon-only pill** (`Discovery`, `Invite via Telegram`, `Save all pending`, `Open PR`) for everyday verbs, or (b) a **`⋯` overflow menu item** (`Invite teammate`, `Convert to project`, `Promote to spec`) for rare / lifecycle-shaped verbs. Action visibility is still a pure function of `(stageView, mode, ownerCaps, pendingCount)` plus the per-handler predicates threaded from `<ChatView>`. The shape vocabulary (G1 labelled / G2 icon-only / G3 chip / G5 segmented / G6 stepper) is specified in `openspec/archive/2026-05-16-redesign-canvas-chrome/design.md` § 3.

---

## Discovery wizard (applied 2026-05-08 from change `add-canvas-discovery-wizard`; relocated to dialog 2026-05-15 by change `move-canvas-top-panels-to-dialogs`)

A six-field structured-intake panel rendered as a **dialog opened from the canvas header strip Discovery pill** (`?dialog=discovery&d_canvas=<id>`) on every `/spec/[id]` page. The wizard captures problem statement, proposed solution, target audience, industry, timeline, and budget at canvas-creation time so Muse skips re-asking these basics on every fresh canvas. Together with the sticky-note `idea-capture` view (RAP-58), it closes Step 1 of the seven-step end-to-end flow.

### Panel composition

`<DiscoveryWizardBody>` at `apps/studio/src/components/canvas/discovery-wizard/discovery-wizard.tsx` composes six fields inside the dialog's content area (no `<Card>` wrapper — the dialog frame replaces it):

| Field | Kind | Cap |
|---|---|---|
| `problem_statement` | textarea | 500 chars |
| `proposed_solution` | textarea | 500 chars |
| `target_audience` | text input | 200 chars |
| `industry` | enum select (11 values) | — |
| `timeline` | enum select (5 ranges + unspecified) | — |
| `budget` | enum select (4 ranges + unspecified) | — |

The schema is single-sourced at `packages/canvas/src/wizard/schema.ts` (the `WizardStateSchema` Zod object, `.strict()`-rejecting unknown keys). All consumers — server actions, lifecycle engine checks, Muse system-prompt builder — import from `@rapoport-studio/canvas/wizard`.

### Per-field autosave

Textareas / text inputs save on **blur**; selects save on **change**. Each save runs through `saveWizardField(canvasId, field, value)` server action with optimistic local update + revert + `toast.error` on rejection. Same-field concurrent saves are last-write-wins; different-field saves don't clobber because the action does read-then-merge-then-write of the full state per call.

### Read-only at the tasks-stage threshold

The panel's `readOnly` is computed at the page level from `canvas_sessions.stage in ('tasks', 'build_ready')`. Earlier stages (`intake`, `discovery`, `concept`, `design`) keep the wizard editable. By the `tasks` stage the discovery facts have been incorporated into the spec; editing them retroactively without a corresponding spec amendment would create incoherence.

### Server actions

`apps/studio/src/app/(auth-walled)/(spec)/spec/[id]/wizard-actions.ts` exports three actions:

- `saveWizardField(canvasId, field, value)` — auth-gated, per-field Zod-validated, atomic JSONB merge via read-merge-write loop. Returns `{ ok: true, state: WizardState }` or `{ ok: false, error: 'invalid_field' | 'invalid_value' | 'not_found' | 'db_error' }`.
- `clearWizardField(canvasId, field)` — resets to Zod default (empty string for textareas, `'unspecified'` for selects).
- `loadWizardState(canvasId)` — read helper. Returns the always-defaulted `WizardState`; never null.

`revalidatePath('/spec/[id]', 'layout')` after every successful write so server-rendered consumers (next Muse turn, page re-render after navigation) see fresh state.

### Muse integration

Every Muse turn — Architect / Builder / Research mode — receives a `# Discovery wizard state` preamble at the **top of the system prompt**, before the persona section. The preamble lists already-answered fields as `**Label:** value` bullets so Muse skips re-asking them. Empty wizards see a single-line conversational-fallback hint instead.

The studio's canvas turn route (`apps/studio/src/app/(auth-walled)/(spec)/spec/[id]/turn/route.ts`) bridges via `tryLoadWizardPreamble`: reads `wizard_state` via `loadWizardState`, filters via `filledWizardFields` from `@rapoport-studio/canvas/wizard`, maps to `{ label, value }` entries via `WIZARD_FIELD_LABELS`, and threads through `runMuseTurnStream → runMuseWithTools → buildSystemPrompt`. Muse stays canvas-package-agnostic; the canvas package stays Muse-agnostic.

### Lifecycle stage signal

Stage 1 (`discovery`, range 10-30) gains two `wizard_field_min_chars` checks contributing 8 weight total (`problem_statement` ≥ 40 chars + `target_audience` ≥ 5 chars), with the existing eight Mirror-perception checks rebalanced from 20 to 12 weight. Per-stage sum stays at 20. The `wizard_field_min_chars` evaluator at `apps/studio/src/lib/stage-checks/evaluators.ts` reads `canvas_sessions.wizard_state` directly; no snapshot extension needed (the `session` row already carries the column).

### Out of scope

Per `openspec/archive/2026-05-08-add-canvas-discovery-wizard/design.md § 10`:

- Multi-step wizard state machine (v1 is six fields with per-field save).
- `read_wizard_state` Muse tool (system-prompt injection covers v1).
- Auto-fill from chat history.
- Auto-promotion of wizard answers into proposal.md.
- Third canvas view in `VIEW_REGISTRY` (the wizard is a panel, not a view).
- Multi-user real-time collaboration on the wizard fields (last-write-wins per field).
- Versioned JSONB shape (unversioned in v1).
- Decision-makers / success-metric fields (deferred).
- Read-only override admin button (deferred).

### Cross-references

- Backing column — `db/entities.md § canvas_sessions.wizard_state`.
- Schema source — `packages/canvas/src/wizard/schema.ts`.
- Server actions — `apps/studio/src/app/(auth-walled)/(spec)/spec/[id]/wizard-actions.ts`.
- Muse system-prompt block — `packages/muse/src/orchestration/wizard_block.ts` (`renderWizardBlock`).
- Lifecycle engine integration — `apps/studio/src/lib/stage-checks/evaluators.ts § evalWizardFieldMinChars`.
- Lifecycle YAML signal — `openspec/_methodology/lifecycle.yaml § stages.discovery`.

---

## Control room (applied 2026-05-09 from change `add-studio-control-room`, Phase 1)

`/control` and `/control/roadmap` form the studio's owner-only operational-dashboards surface. Phase 1 ships exactly one dashboard (Roadmap) plus the surface scaffolding that hosts the rest. Each subsequent dashboard (Engagements / Throughput / Finance / Team) ships as its own change folder layered on the same scaffold.

### Routes

| Route | File | Purpose |
|---|---|---|
| `/control` | `apps/studio/src/app/(auth-walled)/(default)/control/page.tsx` | Index — five `<DashboardCard>` items. Roadmap is `ready`; the four future dashboards are `planned` placeholders. |
| `/control/roadmap` | `apps/studio/src/app/(auth-walled)/(default)/control/roadmap/page.tsx` | Roadmap dashboard — six sections sourced from `openspec/ROADMAP.md`. |

### Access control

`apps/studio/src/app/(auth-walled)/(default)/control/layout.tsx` runs `getUser()` and `notFound()` for non-`platform_owner` viewers. Same pattern as `(dev)/token-check/page.tsx`. Not a security mechanism — middleware already auth-walls everything; this is a UX gate so non-owners don't see an empty surface and the route's existence isn't leaked.

### Sidebar entry

`apps/studio/src/components/studio-sidebar.tsx` renders a `<SidebarMenuItem>` with the `ShieldCheck` Lucide icon and label "Control" inside the `Admin` group (next to Settings), shown only when `platformOwner === true`. Active when `pathname.startsWith("/control")`. `platformOwner` is a separate prop on `<StudioSidebar>`, threaded from `(auth-walled)/layout.tsx` via `user.platform_owner ?? false`. (Pre-RAP-197, this was rendered as a letter-glyph `X` on a `ModeRail` — replaced with the labeled icon row when the chrome consolidated.)

### Bundle dependency

`tools/build-openspec-bundle.mjs` exports three buckets that the dashboard reads at request time:

- `ROADMAP_TEXT` — verbatim `openspec/ROADMAP.md`.
- `CHANGES_INDEX` — `{ slug, files[] }` for every `openspec/changes/<slug>/`.
- `ARCHIVE_COUNT` — count of `openspec/archive/*` entries excluding `superseded/`.

The drift check (`node tools/build-openspec-bundle.mjs --check`, run in CI) covers these alongside the existing OPENSPEC_BUNDLE / MIRROR_BUNDLE / METHODOLOGY_*_BUNDLE shapes.

### Roadmap parser

`apps/studio/src/lib/control/roadmap-parser.ts` — pure server-only module exporting `parseRoadmap(text): ParsedRoadmap` and `RoadmapDriftError`. Hand-rolled walker (no remark dep). On structural drift, throws a typed error; the page catches it and renders `<RoadmapDriftPanel>` with the section name + hint rather than empty cards. Sections covered: sync date, seven-step flow (GFM table), four critical-path tracks (fenced block), Section 2 substrate / open lists, Section 3 active-changes list, Section 4 deferred items, Section 6 drift items, Quick reference KPIs.

### Dashboard composition

`apps/studio/src/components/control/roadmap-dashboard.tsx` is a client component that receives the parsed shape + counts and renders six sections:

1. **HeaderKpis** — five `<KpiTile>` instances (MVP v1 / MVP v1.1 / Active / Archived / ETA).
2. **CriticalPath** — seven-step flow + 4 track cards with `<ProgressBar>` + `<StatusPill>`.
3. **ActiveChangesKanban** — 3 columns (in progress / scaffolded / blocked), one card per change folder.
4. **UnbindSection** — substrate vs. open two-card layout.
5. **DeferredSection** — 6 cards.
6. **DriftSection** — 5 rows.

Three sub-primitives in `apps/studio/src/components/control/_primitives.tsx` — `<StatusPill>` (5 statuses), `<ProgressBar>`, `<FlowStep>`. Token mapping locked per Decision D-7 in the change's design.md — uses `bg-primary/12` / `bg-accent/12` / `bg-destructive/12` / `bg-secondary/40` / `bg-muted` with matching foreground tokens. No `-on-dark` references; no `oklch()` literals.

### Phase roadmap

| Phase | Slug | Status | Scope |
|---|---|---|---|
| **Phase 1 — shipped 2026-05-09** | `add-studio-control-room` | ✅ archived | `/control` route group, owner-only gating, rail entry, `<RoadmapDashboard>` reading from extended OpenSpec bundle, ROADMAP.md parser. |
| Phase 2 | `add-control-engagement-dashboard` | deferred | `/control/engagements` — one card per row in `projects` table joined with `_methodology/projects/<slug>.yaml`, last canvas activity, last forge run. |
| Phase 3 | `add-control-throughput-dashboard` | deferred | `/control/throughput` — PRs/Linear issues closed per week, time-in-status per RAP issue, change-folder velocity. |
| Phase 4 | `add-control-finance-dashboard` | deferred | `/control/finance` — token spend per project, lead conversion from `studio_leads`, MRR placeholder. |
| Phase 5 | `add-control-team-dashboard` | deferred | `/control/team` — pending invitations, specialist network status, founder-track applicants. |

Each Phase 2–5 change activates its corresponding `<DashboardCard>` on the index page by editing the single `control/page.tsx` file.

### Out of scope (locked)

- Live PR / Linear queries — Phase 1 reads only the bundle (build-time data). Live API queries arrive with the throughput dashboard (Phase 3).
- Multi-user access — owner-only is sufficient for v1. Founding-team access arrives with the team dashboard (Phase 5) once roles exist.
- Mobile layout — desktop-first; mobile is read-only fallback.
- ROADMAP.md schema enforcement — the file stays human-authored markdown; a stricter schema (frontmatter + structured tracks) is a follow-up if/when the dashboard becomes load-bearing.

### Cross-references

- Change folder — `openspec/archive/2026-05-09-add-studio-control-room/`.
- Bundle generator extension — `tools/build-openspec-bundle.mjs § Control room bundle extension`.
- Parser tests — `apps/studio/src/lib/control/__tests__/roadmap-parser.test.ts`.
- Rail entry tests — `apps/studio/src/components/__tests__/studio-rail.test.tsx § Control room rail item`.
- Bundle assertions — `apps/studio/src/__tests__/openspec-bundle.test.ts § Control room bundle extension`.

### Engagements dashboard (applied 2026-05-09 from change `add-control-engagement-dashboard`, Phase 2)

`/control/engagements` is the second `<DashboardCard>` on `/control` to ship. It answers *"what is the state of each engagement?"* — one card per row in `projects`, joined with `openspec/_methodology/projects/<slug>.yaml` (via the `METHODOLOGY_PROJECTS_BUNDLE` shipped by Phase 1) and with activity signals from `canvas_sessions` + `forge_runs`.

#### Route

`apps/studio/src/app/(auth-walled)/(default)/control/engagements/page.tsx`. Server component. Owner-gate inherits from `../layout.tsx` (`notFound()` for non-`platform_owner` viewers). The page runs `loadEngagementOverview(supabase)` once per request and hands a fully-validated `EngagementOverview` shape to the client `<EngagementsDashboard>`.

#### Loader contract

`apps/studio/src/lib/control/engagement-loader.ts` exports `loadEngagementOverview(supabase): Promise<EngagementOverview>`. Joins three sources in parallel:

1. `select id, slug, name, status, github_repo, linear_team_id, archived_at, created_at, updated_at from projects order by updated_at desc` — owner-scoped via RLS.
2. `METHODOLOGY_PROJECTS_BUNDLE[<slug>]` — per-engagement YAML read in-memory; no filesystem I/O at request time. Parsed via `js-yaml` into four fields only (`engagement.mode`, `engagement.charter_version`, `engagement.kickoff_date`, `template`). `js-yaml` auto-promotes `2026-05-05`-shaped scalars to JS `Date`; the loader's `coerceYamlDateOrString` helper accepts both Date and string and surfaces ISO `YYYY-MM-DD` for downstream rendering.
3. Activity signals — `loadLastCanvasActivityBySlug` (one round-trip + in-memory fold) + `loadLastForgeRunBySlug` (one indexed query per slug, joined via `linear_issue_key LIKE '<PREFIX>-%'` using the static `resolveLinearPrefixForProjectSlug` map re-exported from `(default)/projects/loaders.ts`).

Failure-isolated per row: missing slug in the bundle, malformed YAML, or missing `engagement.mode` field → `mode='unconfigured'` + warning marker on the row, not a throw. A schema-drift bug that bricks the page is scoped to step 1 (the projects fetch) — the dashboard surfaces a `loaderError` flag with an honest empty state in that case.

#### Dashboard composition

`apps/studio/src/components/control/engagements-dashboard.tsx` is a server component renders five header `<KpiTile>` instances (Total / Active / Archived / Unconfigured / Last activity) plus an ordered list of `<EngagementRow>` per row.

`apps/studio/src/components/control/engagement-row.tsx` renders eight slots per engagement: slug + name (identity column); mode pill; status pill; GitHub binding (or `—`); Linear binding (or `—`); charter version + kickoff date; last-activity relative time. The mode-to-status mapping is locked: `full → done`, `hybrid → inprog`, `deliverables-only → deferred`, `unconfigured → neutral`. Reuses Phase 1's locked Decision D-7 — no new design tokens. Unconfigured rows show an `AlertTriangle` icon + "missing methodology yaml" warning marker. Archived rows render with `opacity-70` dimming.

#### Index page integration

`apps/studio/src/app/(auth-walled)/(default)/control/page.tsx` swaps the disabled `<DashboardCard title="Engagements" status="planned" disabled />` for the `ready` variant linking to `/control/engagements` with the description `"Per-engagement state — mode, last activity, bindings."` This is the entire surface change to the index file — every Phase 2–5 dashboard activates its card by editing this single file.

#### Phase 3–5 deferred

- `add-control-throughput-dashboard` — Phase 3, deferred. Live PR/Linear queries are out of scope for the bundle-only dashboards; throughput will introduce the first request-time API hop.
- `add-control-finance-dashboard` — Phase 4, deferred. Token spend per project + lead conversion + MRR placeholder.
- `add-control-team-dashboard` — Phase 5, deferred. Pending invitations + specialist network status + founder-track applicants.

#### Out of scope (locked)

- **Live PR / Linear queries.** Phase 3 throughput dashboard.
- **Engagement editing.** Read-only surface — editing already lives on `/projects/[slug]`.
- **Archive lifecycle button.** Separate workflow with charter implications.
- **Per-engagement Mirror status.** Already on `/projects/[slug] § MirrorCoverageCard`.
- **Specialist roster surface.** Phase 5 team dashboard.
- **Token spend / cost attribution.** Phase 4 finance dashboard.
- **Mobile layout.** Desktop ≥1024px.
- **Multi-user access.** Owner-only, inheriting Phase 1's gate.
- **New design tokens.** Reuses Phase 1's locked mapping per Decision D-7.
- **Schema migration / new tables / new columns.** Read-only over `projects` + `canvas_sessions` + `forge_runs` + bundled YAML.
- **Bundle generator amendment.** Phase 2 is a consumer of `METHODOLOGY_PROJECTS_BUNDLE`, not a producer.

#### Cross-references

- Change folder — `openspec/archive/2026-05-09-add-control-engagement-dashboard/`.
- Loader source — `apps/studio/src/lib/control/engagement-loader.ts`.
- Loader tests — `apps/studio/src/lib/control/__tests__/engagement-loader.test.ts` (14 cases).
- Row component tests — `apps/studio/src/components/control/__tests__/engagement-row.test.tsx` (5 cases).
- Phase 1 substrate — `archive/2026-05-09-add-studio-control-room/` § Bundle extension (`METHODOLOGY_PROJECTS_BUNDLE`, `<DashboardCard>`, owner-gated layout).

---

## /api/studio/telegram/* surface (applied 2026-05-09 from change `add-tg-canvas-surface`)

### /api/studio/telegram/* (v1, per-canvas Muse surface)

Routes added by `add-tg-canvas-surface` (RAP-121).

- **`POST /api/studio/telegram/webhook`** — Telegram webhook for `@rs_canvas_bot`.
  Auth: Telegram `secret_token` header (`X-Telegram-Bot-Api-Secret-Token`,
  validated against `TELEGRAM_CANVAS_WEBHOOK_SECRET`) + `STUDIO_COMMAND_API_KEY`
  defense-in-depth. Returns 200 within 5s (early-200), processes turn async via
  Cloudflare Queue. Dedups by `tg_message_id`. Runs Haiku classification gate;
  invokes `runMuseHeadless` if addressed; rejects if cap exceeded.

- **`POST /api/studio/telegram/bind`** — JWT invite-token redemption inside bot
  DM handler (NOT a separate route — handled inline by webhook when message
  text matches JWT shape). See `telegram.md § Identity binding`.

- **`GET /tg-bind/[token]?`** — UI page (NOT used in v1 per D-TCS-6 paste-JWT-in-DM).
  Reserved for Q-TCS-A (Telegram Login Widget) future flow.

### Two-bot identity split

`/api/studio/command` is bound to `@rapoport_studio_bot` (Digital Pavel,
standalone). `/api/studio/telegram/webhook` is bound to `@rs_canvas_bot`
(canvas Muse). Two BotFather identities, two webhook secrets, two
`secret_token` values. n8n bridge routes by source bot identity + chat
membership. See `telegram.md § Bot identity (D-TCS-12)` for rationale.

### Studio UI surfaces (added in P5)

- Bind Telegram dialog opened from the canvas header strip "Invite via
  Telegram" pill (`?dialog=bind-telegram&d_canvas=<id>`) — generates JWT
  invite token, shows QR + "DM @rs_canvas_bot and paste this token" copy.
  Owner-only: pill is hidden for non-owners; dialog body renders an
  empty-state for direct-URL hits as defence-in-depth (relocated from inline
  panel 2026-05-15 by change `move-canvas-top-panels-to-dialogs`).
- `/spec/[id]/proposals` — Approve/Reject UI for `canvas_proposals` rows;
  only owner can resolve (RLS-enforced + UI-gated).
- Cost panel daily ledger from `canvas_daily_costs` next to lifetime cost.
- Canvas settings: `daily_cap_cents` field, `tg_surface_enabled` toggle (project-level).

## Studio onboarded as `/projects/studio` (applied 2026-05-09 from change `migrate-studio-into-platform`, RAP-169)

`/projects/studio` is now a live `/projects/[slug]` route. The data substrate registered by this change:

- `projects` row with `slug='studio', name='Rapoport Studio', status='active'`, owner = Pavel.
- `project_connections` rows of `kind='github'` (`rapoport-studio/rapoport.studio`) and `kind='linear'` (RAP team `c48bbbb1-9eba-477d-87d2-d082c506d885`), both `active`. The `linear` config gained a runtime-written `teamKey: 'RAP'` after the first verify pass — informational, not part of the migration.
- Engagement profile at `openspec/_methodology/projects/studio.yaml` (mode `full`, `bot_account: paveliko` — deliberate divergence from external engagements that route commits through `rapoport-studio-bot`).
- Mirror placeholder at `openspec/brands/studio/identity.md` (Tier-2 shell; perception fields populated as the studio runs canvases against itself).

The route reuses the existing `/projects/[slug]` server page (no studio-specific code path). Cards behave as documented in § Project routes:

- **Header** — `Rapoport Studio` + `studio` slug + `active` status badge.
- **Recent canvases** — empty until the first studio-attached canvas runs (Track D Cycle 2 dogfood).
- **Connections card** — two bound rows lit (GitHub + Linear), via the registry-driven `<ConnectionsList>` from `add-project-connections` (RAP-140). No per-kind UI branches.
- **Knowledge card** — empty until the first instruction lands on a studio-attached canvas.

`/projects/studio` resolves equivalently to `/projects/unbind` and the other engagement slugs — the only divergence is the `bot_account` in the engagement profile. The static slug → Linear-prefix map at `apps/studio/src/app/(auth-walled)/(default)/projects/forge-runs-loader.ts` already maps `studio → RAP`, so `<ForgeRunsCard>` (from `add-forge-discovery-surface`, archive `2026-05-08-add-forge-discovery-surface`) lights up immediately when a `forge_runs` row with `linear_issue_key LIKE 'RAP-%'` lands.

Mirror coverage card on `/projects/[slug]` remains deferred per § Project routes "Deferred for v1"; the placeholder file at `openspec/brands/studio/identity.md` lands here so the future card has something to point at.

## Canvas state orchestration (applied 2026-05-09 from change `add-xstate-state-orchestration`)

Replaces the per-component `useState` / `useTransition` / `useEffect` mosaic in the canvas surface with an XState v5 orchestrator (`ideaMachine`) that owns Realtime routing, Server Action lifecycle, and child-domain coordination. See `state.md` for the underlying capability and `archive/2026-05-09-add-xstate-state-orchestration/` for the change record.

### Topology — what shipped

`ideaMachine` is the parent of three long-lived children plus the Realtime bridge actor. The shipped surface differs from the original Wave 2 design (`design.md § Studio amendment`) on two counts:

- **`filesMachine` was deferred indefinitely.** Wave 2A folded the files list and six file-action wrappers (save / saveAll / convertToProject / openPR / saveArtifact / saveFile) into `chatMachine`; the per-file spawn-child topology in the original design adds no behavior over what `chat.machine` already owns and stays out of the topology until a per-file spawn becomes valuable.
- **`costBudgetMachine` was folded into `chatMachine`.** Cost cents are a single `context.costCents` field with a derived `costAtLimit` selector; the `withinBudget → atLimit → exceeded` separate machine added a state region without separate behavior. `canvas_budget_exceeded` is matched via `error.kind === "turn"` rather than a dedicated state.

```
ideaMachine (apps/studio/src/app/(auth-walled)/(spec)/spec/[id]/machines/canvas-machine.ts)
├── input: { canvasId, viewer, projectSlug, supabase, initial* }
├── invoke "realtime"     → supabaseRealtimeBridge (from packages/state)
├── invoke "chat"         → chatMachine (owns messages / artifacts / files / costCents / six action wrappers)
├── invoke "instructions" → instructionsMachine
└── invoke "participants" → participantsMachine
```

States of `ideaMachine` itself: `connecting → connected → reconnecting`. Initial context comes from the `loadCanvasState` server-side load (unchanged) passed in as `input.initial`.

### Child machines — what each owns

| Machine | States | Owns |
|---|---|---|
| `chatMachine` | `idle → sending → streaming → settling → idle`; six non-turn invoking states (`promoting` / `converting` / `savingAll` / `openingPR` / `savingArtifact` / `savingFile`) | Turn lifecycle + SSE consumption (`turnSseStream` `fromCallback`), messages, artifacts, files, status, `costCents`, unified `error`, `actionPending` |
| `instructionsMachine` | `idle → adding / pinning / deleting → idle` | Project instructions list, kind selection, pin state |
| `participantsMachine` | `idle` (refresh substate deferred behind orchestrator's `refetchCanvasState`) | Viewer + invitees list (read-only on the machine; invite / revoke flows stay UI-local in `InvitationModal.tsx`) |

### Realtime fan-out — what shipped

`supabaseRealtimeBridge` is invoked once at the orchestrator level inside the `connected` state, with five subscriptions verified against `loaders.ts`:

```ts
subscriptions: [
  { table: "canvas_messages",             filter: `canvas_id=eq.${canvasId}`, event: "INSERT", eventType: "REALTIME.MSG_INSERTED" },
  { table: "canvas_files",                filter: `canvas_id=eq.${canvasId}`,                  eventType: "REALTIME.FILE_CHANGED" },
  { table: "canvas_project_instructions", filter: `canvas_id=eq.${canvasId}`,                  eventType: "REALTIME.INSTRUCTION_CHANGED" },
  { table: "canvas_invitees",             filter: `canvas_id=eq.${canvasId}`,                  eventType: "REALTIME.PARTICIPANT_CHANGED" },
  { table: "canvas_sessions",             filter: `id=eq.${canvasId}`,        event: "UPDATE", eventType: "REALTIME.COST_UPDATE" },
]
```

`canvas_artifact_emissions` (mentioned as a possibility in the original design) does not exist — artifact emissions are derived from `canvas_files` rows joined back to messages, so the `canvas_files` subscription covers both surfaces.

The orchestrator translates each `REALTIME.*` event into a per-row delta and forwards it to the responsible child via `sendTo`:

| Realtime event | Forwarded to | Child event |
|---|---|---|
| `REALTIME.MSG_INSERTED` | `chat` | `CHAT.UPSERT_MESSAGE` (gated to `idle`) |
| `REALTIME.FILE_CHANGED` | `chat` | `CHAT.UPSERT_FILE` / `CHAT.DROP_FILE` (gated to `idle`) |
| `REALTIME.INSTRUCTION_CHANGED` | `instructions` | `INSTRUCTIONS.UPSERT_ROW` / `DROP_ROW` (guarded on `pending === null`) |
| `REALTIME.PARTICIPANT_CHANGED` | `participants` | `PARTICIPANTS.UPSERT` / `DROP_ROW` |
| `REALTIME.COST_UPDATE` | `chat` | `CHAT.COST_UPDATE` (root-level — cost legitimately rises mid-turn) |
| `REALTIME.DISCONNECTED` | (parent) | targets `.reconnecting` |
| `REALTIME.CONNECTED` | (parent) | targets `.connected` |

The `idle`-state gate on chat deltas is encoded as topology rather than an explicit guard: events arriving while a turn is in flight (`sending` / `streaming` / `settling`) or any non-turn action is invoking are dropped on the floor; reconnect-path `CHAT.RESYNC` reconciles divergence on the next channel close.

### Server Action wiring

All canvas mutations are unchanged at the boundary — the same Server Actions (`sendMessageAction`, `saveFileAction`, `saveAllPendingAction`, `addProjectInstructionAction`, `pinProjectInstructionAction`, `deleteProjectInstructionAction`, `promoteCanvasAction`, `convertToProjectAction`, `openPullRequestAction`, `saveArtifactAction`) — but they are invoked through `serverActionActor` (`fromPromise`) inside the relevant child machine. Each action call resolves to a typed `loading → success / error` transition, replacing the previous `useTransition + try/catch + toast` pattern at the component level.

`runTurn` itself is unchanged. The chat machine's `streaming` state consumes the existing async generator from `runMuseWithTools` via the existing `muse-runner.ts` plumbing (the SSE round-trip stays inline as a canvas-specific `fromCallback` actor — `turnSseStream`). Muse layer is untouched.

### UI consumption

`views/chat-view.tsx` calls `useMachine(ideaMachine, { input })` once at the top. Child surfaces (`chat-column`, `file-tree`, `instructions-panel`, `participants-pill`, the file save action bar) consume their respective child actors via `useSelector(actorRef, s => s.context.<field>)` and `useSelector(actorRef, s => s.children.<name>)` from `@xstate/react`. State of any in-flight mutation is read from machine state, not from local component state.

`apps/studio/src/instrumentation-client.ts` calls `attachInspector()` once at boot. In dev, the Stately inspector window opens automatically and tracks all canvas machines. In production, the inspector is tree-shaken via the dynamic-import-behind-`NODE_ENV` guard in `packages/state/src/dev-inspector.ts` — verified by Wave 1 PR-2 (#275) production-bundle grep.

### Reconnect resilience

The earlier limitation noted in § Mirror runtime in spec (Realtime disconnect could leave subsurfaces stale) is closed at the orchestration layer:

1. `supabaseRealtimeBridge` reports `REALTIME.DISCONNECTED` → parent enters `reconnecting`.
2. Parent invokes `refetchCanvasState` actor (wraps the server action of the same name in `realtime-actions.ts`).
3. Actor returns the full authoritative state. Parent fans out `RESYNC` events to all three children: `INSTRUCTIONS.RESYNC`, `PARTICIPANTS.RESYNC`, `CHAT.RESYNC` (with snapshot payload).
4. Children replace their list state from the snapshot. Chat gates `CHAT.RESYNC` on its `idle` state — a refetch that lands mid-action waits for the action to settle before reconciling.
5. Parent transitions back to `connected`; the bridge auto-restarts on re-entry.

Acceptance is gated on two manual operator checks: a 30-second network drop mid-session and a 15-minute background-tab idle (Supabase channel-throttle scenario). Both must resolve without a page refresh.

### What this does *not* change

- DB schema — no migrations.
- Server Actions — same names, same signatures, same RLS.
- Muse runtime — `runMuseWithTools` and tool-use-context plumbing unchanged.
- Mirror authoring — `write_mirror_field` unchanged at the tool layer.
- Cost-budget refuse semantics — same `canvas_budget_exceeded` error surface, just now driven from a typed machine state.
- File lifecycle (`canvas_files` UPSERT, `canvas_sessions.mode`) — unchanged at the data layer; the machine mirrors the existing invariants in client state.

### Known limitations carried forward

- **Single-canvas-per-machine.** v1 ships one orchestrator per open canvas tab. Multi-tab cross-canvas synchronization is not in scope.
- **Server-side machines.** `acceptLeadAction` and other multi-step Server Actions remain imperative. Refactor to server-side XState is deferred — see archive `design.md § Open 2`.
- **Inspector cloud sync.** Local-only by design; Stately Studio cloud not adopted.
- **`filesMachine`.** Per-file spawn-child topology stays out of the parent until concrete behavior gap surfaces — `chatMachine` owns file state today.

## Workspace sidebar (applied 2026-05-10 from change `add-studio-sidebar`, superseded 2026-05-10 by `consolidate-studio-sidebar-with-brand`)

The auth-walled workspace shell renders a single labeled `<Sidebar>` as the only chrome surface — workspace identity in the header (`BrandMark`), labeled nav in the content groups, account identity in the footer (`UserMenu`). The dual-surface composition introduced by RAP-194 (`<ModeRail>` + `<StudioSidebar>` side-by-side) is gone; see § Workspace chrome consolidation below for the canonical single-surface composition.

### Composition

`apps/studio/src/app/(auth-walled)/layout.tsx` reads the `sidebar_state` cookie via `next/headers` `cookies()` (server side) and passes the resolved value as `defaultOpen` to `<SidebarProvider>` — first-visit default is open. The provider wraps a flat tree:

- `<StudioSidebar platformOwner={...} user={...} />` — `apps/studio/src/components/studio-sidebar.tsx`
- `<SidebarInset>{children}</SidebarInset>` — page content lands here

There is no outer `AppShell` / `AppShellRail` and no `transform-gpu` containing-block workaround — the previous CSS hack was only needed when a sibling rail forced the sidebar's `fixed inset-y-0 left-0` to escape its containing block.

**Root layout overlay requirement.** `<OverlayHost registry={registry} />` must be mounted as a **sibling of `<SidebarProvider>`**, not nested inside it. Placement inside `<SidebarProvider>` causes the overlay backdrop to inherit the sidebar's containing-block transform and breaks fixed positioning. The registry is defined in `apps/studio/src/overlay/registry.ts`. Full overlay contract in `openspec/current/linkable-ui.md`.

### Icon catalog

Nine top-level entries declared inline as `NAV_SECTIONS` in `studio-sidebar.tsx`, grouped as Workspace / Build / Admin. Each entry pairs a Lucide icon with a sentence-case label and a `match(pathname)` predicate. Catalog locked in `design-components.md § Workspace sidebar icon map`.

| Group | Item | Lucide | Active match |
|---|---|---|---|
| Workspace | Dashboard | `LayoutDashboard` | `pathname === "/"` |
| Workspace | Canvases | `Layers` | `pathname === "/canvases"` or starts with `/spec/` |
| Workspace | Projects | `FolderKanban` | starts with `/projects` |
| Workspace | Inbox | `Inbox` | starts with `/inbox` |
| Build | Forge | `Hammer` | starts with `/tasks` or `/forge/` |
| Build | Templates | `FileText` | starts with `/templates` |
| Build | Network | `Network` | starts with `/network` |
| Admin | Control (owner-only) | `ShieldCheck` | starts with `/control` |
| Admin | Settings | `Settings2` | `pathname === "/settings"` |

Active state renders `--sidebar-accent` background plus a 2-pixel `--sidebar-primary` (terracotta) left rule via `data-[active=true]:before:` Tailwind utilities — Deviation D1 in the archived RAP-194 design. Primitive untouched.

### Header and footer

`<SidebarHeader>` carries `<StudioLogo>` whose variant and motion are bound to `useSidebar().state` — expanded shows `variant="wordmark" motion="morph"` with `secondary="Studio"`, collapsed shows `variant="glyph"`. `<SidebarFooter>` carries `<UserMenu>` (avatar + dropdown with name / email / sign-out). Both slots were intentionally empty in RAP-194 (deviations D-2 / D-6 because the rail handled identity); the consolidate change reverses both.

**`secondary="Studio"` is universal** — all consumers pass Title-Case. RAP-197 D-3's per-app lowercase carve-out (`secondary="studio"` in studio, `secondary="Studio"` in marketing) is superseded by D-20/D-21 from `archive/2026-05-12-evolve-brand-mark-into-studio-logo/`; Composition B's uppercase tracking on the mono secondary reads as a typo with a leading-lowercase glyph at 36 px.

### Topbar

`(default)/layout.tsx` wraps the padded content with a 56-pixel topbar containing `<SidebarTrigger>` and a breadcrumb slot. Pages opt into breadcrumbs by rendering into the slot; default is empty.

`(canvas)/layout.tsx` and `(forge)/tasks/[key]/page.tsx` do not render the topbar — they are siblings of `(default)` in the route group, full-bleed by design. Both layouts use `<div>` instead of `<main>` since `<SidebarInset>` already provides the `<main>` landmark.

### Cookie persistence

Collapse state writes to the `sidebar_state` cookie (`SIDEBAR_COOKIE_NAME` constant in `packages/ui/src/components/sidebar.tsx`). Server-side read of this cookie + `defaultOpen` prop on the provider keeps SSR and client hydration aligned — no flash, no hydration-mismatch warning. First-visit default = open (cookie absent → `defaultOpen = true`).

### Owner gate

The Control entry (the ninth item) renders only when `platformOwner === true`. The flag is threaded from `(auth-walled)/layout.tsx`'s server-side `getUser()` call (`user.platform_owner ?? false`).

### Mobile

Below `md` (768px), `<Sidebar>` falls back to a left-side `<Sheet>` at 18rem (built-in `SIDEBAR_WIDTH_MOBILE` from the primitive — consumed as-is). `aria-label` survives in `collapsible="icon"` mode via the primitive's tooltip-driven name, so the nav remains accessible without visible labels.

### Change source

- RAP-194 introduction (rail + sidebar side-by-side): `openspec/archive/2026-05-10-add-studio-sidebar/`. PR: #482.
- RAP-197 consolidation (single sidebar, BrandMark, UserMenu in footer): `openspec/archive/2026-05-10-consolidate-studio-sidebar-with-brand/`. PR: #488.

## Workspace chrome consolidation (applied 2026-05-10 from change `consolidate-studio-sidebar-with-brand`)

The dual-surface composition shipped in RAP-194 (`<AppShell>` + `<AppShellRail>` carrying `<StudioRail>` + `<AppShellMain>` containing `<StudioSidebar>`) is gone. The canonical chrome is now a single labeled `<Sidebar>` carrying both workspace and account identity. See § Workspace sidebar above for the live composition.

### What was removed

- `apps/studio/src/components/studio-rail.tsx` and its test (`studio-rail.test.tsx`)
- `packages/ui/src/components/mode-rail.tsx` (`ModeRail`, `ModeRailItem`)
- `packages/ui/src/components/app-shell.tsx` (`AppShell`, `AppShellRail`, `AppShellMain`)
- CSS tokens `--rail-bg`, `--rail-active` and their `@theme inline` mappings — see `design-components.md § Workspace chrome consolidation` for surviving consumers' migration

### What was added

- `packages/ui/src/components/brand-mark.tsx` — shared brand primitive used by both `apps/studio` (sidebar header, two variants driven by `useSidebar().state`) and `apps/web` (nav, `secondary="Studio"` Title Case)

### Supersedes

This change explicitly supersedes deviations from `archive/2026-05-10-add-studio-sidebar/`:

- **D-1** — "keep both ModeRail + StudioSidebar (two surfaces, two jobs)"
- **D-6** — "omit `<SidebarFooter>` because UserMenu lives on the rail"

Both rationales remain valid as historical context; the canonical chrome from this date forward is the single-surface composition. The Control room rail glyph (RAP-178) becomes a labeled `SidebarMenuItem` in the Admin group.

### Change source

Full prose proposal + design + tasks: `openspec/archive/2026-05-10-consolidate-studio-sidebar-with-brand/`. Linear: [RAP-197](https://linear.app/rapoport-studio-slr/issue/RAP-197). PR: #488.

---

## Network surface (applied 2026-05-10 from change `add-network-studio-surface`, RAP-97)

`/network` is the auth-walled member-only network surface — the studio sibling to the public read-only `/network` view on `apps/web` (RAP-96). It composes seven Suspense-wrapped sections plus a hero: full-data variants of the four shared sections (Now active, Open calls, Mind map, Channels) and three studio-only sections (Discussions § 2.A, Pending invitations § 2.B, Skill gap § 2.C).

The page lives at `apps/studio/src/app/(auth-walled)/(default)/network/`. Auth gate is inherited from `(auth-walled)/layout.tsx` — anonymous users redirect to `/login?error=session_expired`; member sessions render the page. There is no in-page member check beyond the layout-level gate; per-section RLS handles row visibility.

### Server actions

`apps/studio/src/app/(auth-walled)/(default)/network/actions.ts` exports four actions:

| Action | Effect | Auth gate |
|---|---|---|
| `postOpenCallAction(formData)` | Inserts an `open_calls` row. Sets `posted_by_user_id` from `session.user.id` (never the form payload). Zod-validates title (8–200), description (40–4000), `skills_needed[]` (1–8 UUIDs), urgency, and the `is_public_in_network` toggle. | Member session |
| `convertDiscussionToProjectAction(formData)` | Idempotent discussion → project conversion (D-3 / D-4). Re-call on an already-converted discussion returns `{ ok: true, payload: { projectId, alreadyConverted: true } }` without side effects. On first call: inserts `projects` (`is_public_in_network=false`), inserts `canvas_sessions` seed, inserts the first `canvas_messages` row with the discussion summary as system context, then back-links via `discussions.converted_to_project_id`. Slug collisions surface as `slug_taken: <slug>` (Postgres `23505` unique-violation). | Member session |
| `markChannelPublicAction(formData)` | Toggles `channels.is_public_in_network`. Reads `app_metadata.platform_owner` from the JWT and rejects with `not_platform_owner` ahead of any DB call. RLS enforces the same gate as defense-in-depth. | **Platform-owner only** (D-6) |
| `closeOpenCallAction(formData)` | Updates `open_calls.status` to `filled` or `closed` and sets `closed_at = now()`. Maps the UX-level `cancelled` resolution onto the schema's `closed` status (the `open_call_status` enum is `open | filled | closed`). RLS enforces poster-only or platform_owner update. | Member session |

All four actions:

- Use a single `requireMemberSession()` helper that wraps `getServerSupabaseClient()` + `auth.getUser()` and surfaces `not_authenticated` ahead of any DB call.
- Parse `FormData` via a shared `parseFormData()` that coerces repeated `key[]` entries into arrays.
- Return a discriminated `ActionResult` union: `{ ok: true, payload? }` or `{ ok: false, error: string }`.
- Call `revalidatePath('/network')` on success. The web `/network` route (apps/web) reads the public-filtered subset of these tables and lives in a separate Next.js worker — the studio-side `revalidatePath` invalidates the studio render only; cross-surface invalidation is tracked as Phase 4 follow-up alongside the e2e check.

### Skill gap materialized view

`/network § 2.C` reads from `public.skill_specialist_counts`, a daily-refreshed materialized view that aggregates `skills × user_skills × user_settings`. Refresh schedule is wired via `pg_cron` (`@ 0 4 * * *`) when the extension is enabled; manual refresh is always available via `REFRESH MATERIALIZED VIEW CONCURRENTLY public.skill_specialist_counts;`. The fetcher tolerates the "relation does not exist" error code (`42P01`) so the section renders the healthy-coverage empty state on stacks where the prerequisite RAP-95 schema or the MV migration has not been applied yet.

### Out of scope

Per `openspec/archive/2026-05-10-add-network-studio-surface/proposal.md § Out of scope`:

- Web-side public rendering — owned by RAP-96.
- Channel ingestion writes — owned by RAP-99.
- AI-suggested discussion → project conversion — RAP-93 Q3, deferred behind a feature flag.
- Real-time updates on the studio surface — first ship is server-rendered snapshots.
- Editing of specialist profile fields from `/network` — lives on `/settings`.
- Playwright e2e (Phase 4 task 3) — deferred to follow-up.
- `open_calls.filled_by_user_id` (D-11) — deferred; would require a schema amendment to the already-archived RAP-95.

### Change source

Full prose proposal + design + tasks: `openspec/archive/2026-05-10-add-network-studio-surface/`. Linear: [RAP-97](https://linear.app/rapoport-studio-slr/issue/RAP-97).

## Linear OAuth connection (applied 2026-05-10 from change `add-linear-oauth-connection`)

Three new endpoints under `/api/integrations/linear/{connect,callback,disconnect}` add a per-user Linear OAuth binding parallel to the existing bot-token path. `<IntegrationsCard>` on `/settings` (`apps/studio/src/app/(auth-walled)/(default)/settings/integrations-card.tsx`) renders Connect/Connected branches with status banner driven by `?integration=linear&status=…&reason=…`.

`apps/studio/src/lib/linear-oauth.ts` exports `loadLinearForUser(userId)` and a `LinearActorClient` (raw fetch against `https://api.linear.app/graphql`). Custom errors `LinearTokenRevokedError`, `LinearUpstreamError`, `LinearRequestError` allow callers to fall back to the bot path on 401.

**Precedence rule**: in `/api/studio/command`, when the request resolves to a `platform_owner` user with an active Linear OAuth row, the user's actor-bound client is preferred over the bot for `create_task`. The bot remains the fallback (no connection, or revoked token). The Telegram bridge attributes new issues to the connecting user, not the bot, when that user is connected.

Authoritative source: `archive/2026-05-10-add-linear-oauth-connection/design.md §§ 5, 8, 9`.

## URL-state overlay system cross-references (applied 2026-05-10 from change `add-url-state-overlay-system`, RAP-308)

Added root layout overlay requirement to § Workspace sidebar › Composition: `<OverlayHost registry={registry} />` must be mounted as a sibling of `<SidebarProvider>`, not nested inside it, to preserve fixed-positioning behavior. The registry is `apps/studio/src/overlay/registry.ts`. Full overlay contract in `openspec/current/linkable-ui.md`.

## Frontend runtime conformance (applied 2026-05-11 from change `add-frontend-runtime-standards`)

Per-route tier and adapter declarations for `apps/studio`, satisfying [`frontend-runtime.md`](frontend-runtime.md). Tiers per `frontend-runtime.md § Staleness tiers`. The "Current state" column describes what is in code today; the tier column describes the contracted target once the apply-phase audits (Epic RAP-266) land.

| Route | Tier (target) | Adapter | Current state | Notes |
|---|---|---|---|---|
| `/` (dashboard) | OPERATIONAL | XState | uncached | wrap aggregated reads in `'use cache'` + `cacheLife('minutes')` |
| `/canvases` (list) | OPERATIONAL | XState | uncached | per-org list |
| `/spec/[id]` (canvas chat) | HOT | XState (`supabaseRealtimeBridge`) | conforms | data renders client-side from realtime cache (Layer 3 HOT-tier rule) |
| `/spec/[id]/knowledge` | HOT | XState | conforms | document panel — multi-user edits |
| `/spec/[id]/proposals` | HOT | XState | conforms | active artefacts panel |
| `/spec/[id]/turn` (route handler) | — | — | `force-dynamic` (kept) | API endpoint, not a page; per-request `headers()`/`cookies()` |
| `/settings` | REFERENCE | XState | uncached | profile / API key / appearance |
| `/settings/team` | REFERENCE | XState | uncached | team membership — rare edits |
| `/projects` | OPERATIONAL | XState | uncached | project listing |
| `/projects/[slug]` | OPERATIONAL | XState | uncached | project detail |
| `/inbox` | OPERATIONAL | XState | uncached | inbox listing |
| `/inbox/[id]` | OPERATIONAL | XState | uncached | inbox detail |
| `/network` | REFERENCE | XState | uncached | member directory |
| `/templates` | REFERENCE | XState | uncached | template listing |
| `/templates/[id]` | REFERENCE | XState | uncached | template detail |
| `/control` | OPERATIONAL | XState | uncached | control room index |
| `/control/engagements` | OPERATIONAL | XState | uncached | engagement overview |
| `/control/roadmap` | OPERATIONAL | XState | uncached | roadmap dashboard |
| `/tasks` | OPERATIONAL | XState | uncached | Forge task list |
| `/tasks/[key]` | HOT | XState | uncached + legacy `force-dynamic` | target: realtime + drop `force-dynamic` |
| `/login`, `/community/*` | — | — | default dynamic | auth flow per `frontend-runtime.md § Layer 1 § force-dynamic` |

### `force-dynamic` instances at apply time

Two occurrences in `apps/studio` as of 2026-05-11:

- `apps/studio/src/app/(auth-walled)/(spec)/spec/[id]/turn/route.ts` — route handler (not a page); the directive is correct for API endpoints calling `headers()` / `cookies()` per-request. **Kept.**
- `apps/studio/src/app/(auth-walled)/(forge)/tasks/[key]/page.tsx` — legacy `force-dynamic` predating the realtime contract. **Target: remove** as part of the Forge-task realtime migration (tracked under Epic RAP-266).

### OpenNext configuration target

`apps/studio/open-next.config.ts` will be upgraded from the current empty `defineCloudflareConfig({})` to the R2 + DO sharded tag + DO queue shape with regional cache wrap, declared in `frontend-runtime.md § Layer 1 — OpenNext backing`. The migration ships alongside the first `'use cache'` wrap so the smoke test (Layer 1 § CI smoke test) can verify hit-rate end-to-end against staging.

Version pins (apply-time): `next ≥ 16.2.3` and `@opennextjs/cloudflare ≥ 1.19.0` — atomic bump per `frontend-runtime.md § Where this lives`.

Full contract: [`frontend-runtime.md`](frontend-runtime.md).

## Canvas Mirror authoring panel — audience editor (applied 2026-05-13 from change `add-document-zone-permissions`)

The Mirror authoring panel at `apps/studio/src/components/canvas/mirror-authoring/` gains per-item and per-section audience controls. All components live in that directory.

### Audience chip (`audience-chip.tsx`)

Small pill rendered next to each list row and each section header. Displays abbreviated form:

| Audience value | Displayed chip |
|---|---|
| `['*']` | `*` |
| `['*-client']` | `*-client` |
| N specific roles | `[N]` (e.g. `[3]`) |
| `[]` (suppressed) | strike-through `[]` |

A lock icon appears beside the chip when the audience is restricted (anything other than `['*']`). A suppressed item (`[]`) shows a grey strike-through with tooltip "Suppressed (no audience). Visible only to platform_owner via Show raw."

### Audience editor popover (`audience-editor-popover.tsx`)

Opens on chip click. Contains:
- Checkboxes for each of the six concrete roles.
- Shortcut buttons: `*` (select all), `*-client` (deselect client), `internal` (select owner + co_owner only).
- Save → calls server action `updateMirrorFieldAudience(slug, fieldPath, newAudience)` from `mirror-actions.ts`.

**OPERATOR-BLOCKED**: DB write via `updateMirrorFieldAudience` requires the Phase 3 backfill migration to be applied to live `canvas_files` rows before real Mirror perception paths exist to write to.

### Inspection panel — "Show raw" toggle

Visible to `owner` / `co_owner` only (via `viewerRole` check in server component). Located in the panel header.

- **On enable:** orange warning banner "Raw view; bypasses audience tags. For studio inspection only." Switches loader from `loadMirrorForRole(slug, viewerRole)` to `loadMirrorRaw(slug)`.
- **On disable:** revert to filtered view.
- Toggle state persists via URL query param `?raw=1` per `linkable-ui.md § URL = state` convention.

### Section audience editor

Body section headers (`## Mental model`, etc.) display the section's audience inline: "Mental model · audience: [3 roles]". Clicking the audience text opens the same `<AudienceEditorPopover>`. Section body editing is unchanged.

Authoritative source: `archive/2026-05-13-add-document-zone-permissions/design.md § 6`.

## Specialist onboarding surface (applied 2026-05-13 from change `add-specialist-onboarding`)

Six routes shipped across PRs #936 (public intake) and #941 (auth-walled profile + inbox). See updated `§ Surface area` table above for canonical route paths and groups.

### API endpoint

| Route | File | Auth | Purpose |
|---|---|---|---|
| `/api/studio/specialist-application` | `apps/studio/src/app/api/studio/specialist-application/route.ts` | magic-link session | POST handler — validates 14-field intake schema, writes to `specialist_applications` table (gracefully degrades until DB migration lands), returns 201. |

### Graceful degradation note

The UI is complete. All routes and server actions are live. The `specialist_applications` and `specialists` DB tables are implemented as manual TypeScript type stubs in `packages/db/src/types.ts` pending the Supabase migrations (⚠️ operator-blocked). Until migrations land, submissions return 201 but do not persist, and profile/inbox surfaces display empty states.

## Project members surface (applied 2026-05-15 from change `redesign-canvas-permission-model`)

Project-level membership gives every project a roster. The UI surfaces below are per-project and per-canvas additions to the workspace. Role definitions and the full operation matrix live in `db/entities.md § Project members + role hierarchy`.

### Route: `/projects/[slug]/members`

Members page route group: `(auth-walled)/(default)/projects/[slug]/members/page.tsx` (server component). File: `apps/studio/src/app/(auth-walled)/(default)/projects/[slug]/members/page.tsx`.

| Block | Content | Permission |
|---|---|---|
| Page header | Project name + member count + "Invite" CTA | CTA shown for `owner` / `co_owner` / `collaborator` |
| Members table | Avatar, name, email, role chip, invited-on, accepted-on, expires-on (if set), last-active | All members can read; only `owner` / `co_owner` see action menu |
| Action menu (per row) | Change role / Set expiry / Revoke / Re-invite | Disabled for self-row (cannot change own role) |
| Pending invitations | Sub-table of rows with `accepted_at IS NULL` | Same permissions as members table |
| Revoked / expired | Collapsible section, audit trail | Read-only; for compliance |

Loaders in `apps/studio/src/app/(auth-walled)/(default)/projects/[slug]/members/loaders.ts` — three separate queries: active members, pending invitations, revoked/expired subset.

Empty state: "No members yet. Invite your first collaborator."

### Invite modal — `?dialog=invite-member`

Overlay per `linkable-ui.md` convention. File: `apps/studio/src/modules/team/invite-member.dialog.tsx`.

Form fields:
- **Email** — required, validated.
- **Role** — picker filtered by inviter's role per the operation matrix (e.g. `collaborator` cannot pick `co_owner` or `external_collaborator`).
- **Expires after** — picker: never / 7 days / 30 days / 90 days / custom date.
- **Welcome note** — optional free text, included in the magic-link email.

On submit: server action `inviteMember(projectId, email, role, expiresAt, note)` (in `apps/studio/src/app/actions/project-members.ts`) → INSERT into `project_members` (accepted_at NULL) → `signInWithMagicLink` → success toast + dialog closed.

### Canvas settings panel — members section

File: `apps/studio/src/components/canvas/settings-panel/members-section.tsx`.

- Read-only list of effective members (`loadEffectiveCanvasMembers(canvasId)` — project members minus exclusions plus direct canvas invitees).
- Per-row "Exclude from this canvas" toggle — visible to `owner` / `co_owner` only; calls `excludeFromCanvas` / `removeCanvasExclusion` server actions.
- "Invite directly to this canvas" CTA — opens the invite modal with `?canvas=<id>`; resulting row goes to `canvas_invitees` (per-canvas) rather than `project_members` (project-wide).

### Role badges in chat

File: `apps/studio/src/components/canvas/chat/message-author.tsx` (pending Phase 4 completion).

Badge renders inline next to the author display name:

| Role | Badge | Color |
|---|---|---|
| `owner` | Small filled circle | Brand-orange |
| `co_owner` | Small outlined circle | Brand-orange |
| `collaborator` | (no badge — default) | — |
| `external_collaborator` | Small "ext" pill | Neutral grey |
| `viewer` | Small "view" pill | Neutral grey |
| `client` | Small "client" pill | Brand-blue |
| `platform_owner` | Small "studio" pill | Brand-orange (overlaid on personal role) |

Badges link to `/projects/[slug]/members#user-<id>`. Badge data source: `loadEffectiveCanvasMembers` projection on canvas page load, cached for the message-rendering loop. Defensive rendering: no badge when member row is absent (legacy data).

### Project page header — member avatar stack

File: `apps/studio/src/components/projects/member-avatar-stack.tsx`. Wired into `apps/studio/src/app/(auth-walled)/(default)/projects/[slug]/page.tsx`.

Displays first 5 accepted members + "+N more" overflow chip. Click-through to `/projects/[slug]/members`.

### Server actions (`apps/studio/src/app/actions/project-members.ts`)

| Action | Permission | Purpose |
|---|---|---|
| `inviteMember(projectId, email, role, expiresAt?, note?)` | `owner` / `co_owner` / `collaborator` (filtered by role-picker) | INSERT `project_members` + send magic-link |
| `changeMemberRole(projectId, userId, newRole)` | `owner` / `co_owner` (owner-only for promotions to `owner` / `co_owner`) | UPDATE `project_members.role` |
| `revokeMember(projectId, userId)` | `owner` / `co_owner` | Stamp `project_members.revoked_at` |
| `setMemberExpiry(projectId, userId, expiresAt?)` | `owner` / `co_owner` | UPDATE `project_members.expires_at` |
| `excludeFromCanvas(canvasId, userId, reason?)` | `owner` / `co_owner` | INSERT `canvas_excluded_members` |
| `removeCanvasExclusion(canvasId, userId)` | `owner` / `co_owner` | DELETE `canvas_excluded_members` row |
| `transferOwnership(projectId, newOwnerUserId)` | `owner` only; newOwner must be existing accepted member | Atomic swap of `owner` rows |

Permission helper `requireProjectRole(projectId, allowedRoles)` and `loadEffectiveCanvasMembers(canvasId)` live in `apps/studio/src/lib/permissions.ts`. Both perform server-side auth checks; defense-in-depth mirrors the RLS source of truth.

Authoritative source: `openspec/archive/2026-05-13-add-specialist-onboarding/design.md §§ 4–5`.

---

## Cookie disclosure surface (archive `2026-05-15-harden-cookie-transparency`)

The workspace operates under the same cookieless-by-default posture as the marketing site (D-CT-1), but legitimately writes a small set of **strictly-necessary** session cookies and **functional** browser-local state. No analytics, advertising, or third-party tracking identifiers are written by this surface.

**Workspace identifier set** (full schedule in `_legal/cookie-inventory.md` § Workspace, machine-readable for the build-time loader in `apps/web/src/lib/cookie-inventory.ts`):

| Identifier | Type | Class | Code reference |
|---|---|---|---|
| `sb-<project-id>-auth-token` | HTTP cookie (HttpOnly, Secure, SameSite=Lax, `domain=.rapoport.studio`) | Strictly necessary — session JWT + refresh token, contract performance | `packages/auth/src/supabase.ts`, `apps/studio/src/lib/supabase-server.ts` |
| `sb-<project-id>-auth-token-code-verifier` | HTTP cookie (HttpOnly, Secure, SameSite=Lax) | Strictly necessary — PKCE handshake verifier (~5 min) | `packages/auth/src/supabase.ts` |
| `sidebar_state` | JS-set cookie (SameSite=Lax, 7-day max-age) | Functional — sidebar open/collapsed persistence | `packages/ui/src/components/sidebar.tsx` |
| `canvas:mobile-banner-dismissed` | `localStorage` | Functional — one-shot mobile-warning guard | `apps/studio/src/components/canvas-mobile-warning.tsx` |
| `canvas-stage-transition:<canvasId>:<from>:<to>` | `localStorage` | Functional — per-canvas one-shot celebration animation guard | `apps/studio/src/lib/canvas-progress/stage-celebration.ts` |
| `intake-draft:v1:<type>:<locale>` | `localStorage` (30-day schema-expiry) | Functional — intake-form draft persistence | `packages/ui/src/lib/form-draft-storage.ts` |
| `cookie-notice-dismissed:v1` | `localStorage` | Functional — one-shot cookie-transparency-notice dismissal guard | `packages/ui/src/lib/cookie-notice.ts` |
| `anon-visitor-id` | `localStorage` | Functional — anonymous UUID for `consent_events` logging; no PII linkage | `apps/web/src/lib/anon-visitor.ts` |

The last two rows (`cookie-notice-dismissed:v1`, `anon-visitor-id`) were added by `2026-05-16-harden-cookie-compliance-followup`. They are written by the marketing-surface cookie transparency notice (see `web.md § Cookie transparency notice`) but are listed here because the inventory convention groups all `localStorage` identifiers under the workspace audit scope. The marketing surface still sets only one **cookie** (`NEXT_LOCALE`); these two are `localStorage` entries.

**Forensic anchors.** Every write site above carries a `cookie-inventory:` comment anchor pointing at the inventory section. `git grep -n 'cookie-inventory:'` returns the complete map.

**Cross-subdomain cookie domain.** The Supabase auth cookies use `domain=.rapoport.studio` so that the marketing site recognises the authenticated session and surfaces the "Workspace →" affordance. Convention is owned by `auth.md § Cookie domain handling` — the cookie-inventory documents the disclosure surface, not the cross-subdomain mechanism.

**Adding a new identifier to the workspace.** Authoring is allowed; the change is to (a) add a row to `_legal/cookie-inventory.md` § Workspace, (b) add the `cookie-inventory:` comment anchor at the write site, (c) link the resulting `/legal/cookies` workspace-table row from the surrounding feature work. The CI gate (`tools/verify-cookieless-marketing.mjs`) does **not** scan `apps/studio/` — the workspace is explicitly out of its scope; inventory + annotations are the enforcement mechanism here.

## Threads rail — project change timeline (applied 2026-05-16 from change reframe-canvas-change-pipeline)

> Deep contract for the **left rail** pane of the `/spec/[id]` page.
> Page-level composition lives at § Spec page surface. The Ideas surface
> (originally scaffolded as `add-ideas-rail-view`, cancelled by RAP-1168
> Wave 7 — to return rebased on the new project → spec → idea model) is
> reserved under the **chat column** view registry (`?view=ideas`), not
> in this rail — see D-SP-2.

The `/spec/[id]` left **Threads** rail is reframed from a viewer-scoped flat list of every canvas the viewer can reach into a **project-scoped chronological timeline** of the project's changes — the history of how the project grew (first idea, then changes layered on top).

- **Data source.** `loadProjectTimeline(supabase, projectId, currentCanvasId)` (`canvas/[id]/loaders.ts`) returns every canvas in the active canvas's project ordered by `created_at` ascending. A canvas with no `project_id` falls back to a single-entry timeline (itself). RLS scopes the query to canvases the viewer can reach. `CanvasSession` gains `projectId` (distinct from the Mirror-binding `projectSlug`) to drive this.
- **Row content.** Each entry shows the change title, a relative created-at time, a compact P·D·T preview of `proposal` / `design` / `tasks` triplet-file presence, and a visual-only `draft` status pill. `ThreadListItem` / `ThreadRow` (`@rapoport-studio/ui`) gain optional `triplet` + `status` fields to carry this.
- **Approval status is a placeholder** (D-CCP-6) — no data binding, no behaviour. The real approve / signoff mechanism is deferred (Q-CCP-1).

This supersedes the viewer-scoped framing of the `fix-canvas-thread-rail` change (D-CCP-5); that change shipped the rail's data plumbing via RAP-920 and is archived at `openspec/archive/2026-05-16-fix-canvas-thread-rail/`. The right-rail Change · Spec · Code reframe from the same change is documented in `muse.md § Canvas surface — Change · Spec · Code pipeline`.

Source: `openspec/archive/2026-05-16-reframe-canvas-change-pipeline/`.

---

## Spec preview in canvas mirror panel (applied 2026-05-20 from change `add-spec-preview`, RAP-1137)

The mirror panel of `/spec/[id]` carries a live in-canvas projection of the in-flight openspec change when the canvas is in `?view=preview`. Source-of-truth contract lives in [`spec-preview.md`](../spec-preview.md); `studio-workspace` only documents the host relationship.

- **Surface.** `Preview applied change` SmartPill in `apps/studio/src/app/(auth-walled)/(spec)/spec/[id]/artifact-panel.tsx`, rendered above the existing `Promote to /openspec/changes` pill. Soft (preview) above hard (promote); both `tone="accent"`, full-width.
- **Trigger semantics.** On click, the canvas writes `?view=preview` via `window.history.replaceState` (shallow, no RSC round-trip — matches the precedent from `add-canvas-preview-mode` / RAP-1006). Toggling out drops the snapshot; re-entering recomputes from scratch (D-SP-7).
- **Data flow.** `<CanvasMirrorPanel>` (the shell shipped by RAP-1006) gains a `canvasId` prop and on `open === true` calls `loadSpecPreviewAction` → `loadSpecPreview(canvasId, supabase)` → returns a `SpecPreviewProjection` that renders inside `<SpecPreviewContent>`. Fetch is gated on `open === true` so collapsed panels don't burn server calls.
- **Source of current openspec.** A build-time snapshot at `apps/studio/src/generated/openspec-current.json` (gitignored; regenerated by postinstall + `pnpm build` via `apps/studio/scripts/build-openspec-bundle.ts`). D-SP-10 trades off live-FS freshness for Cloudflare-Workers determinism.
- **Out of scope here.** Audience-aware variants (D-SP-9), section-level diffs (D-SP-5), and final visual polish (D-SP-5 / follow-up `redesign-spec-preview-mirror`) live in their respective tracks.

Source: `openspec/archive/2026-05-20-add-spec-preview/`.

---

## Spec viewer download affordance (applied 2026-05-18 from change `add-openspec-context-archive`, RAP-1117)

Closes accumulated drift in `§ Surface area`: the `/specs/[...slug]` route shipped via RAP-975 (#1350, "render OpenSpec citations as deep links") and the parent `/specs` route was renamed from `/canvases` in #1420 — both without amending this spec. Both rows are now in the table above. The `CanvasesPage` function name in `apps/studio/src/app/(auth-walled)/(default)/specs/page.tsx` is left alone (separate cleanup).

The detail page at `/specs/[...slug]` gains a top-right Lucide `Download` icon (`<a download>` element with `aria-label="Download OpenSpec archive"`) pointing to `https://rapoport.studio/openspec-flat.zip`. The list page at `/specs` deliberately does **not** carry the affordance — that page renders the viewer's own canvases, not OpenSpec content, so a "Download OpenSpec archive" button there would be a UX non-sequitur.

The zip behind that URL is built deterministically on every `pnpm install` by `tools/build-openspec-flat-context.mjs --zip apps/web/public/openspec-flat.zip --zip-only`. It contains a flattened copy of `openspec/{AGENTS.md,decisions.md,open-questions.md,ROADMAP.md,_root,current,changes}` (nested paths encoded into filenames with `__`), respects `openspec/.publish-manifest.yml § exclude` (defence-in-depth: `_internal/`, `_research/`, `*-confidential.md`, `*-private.md`, `projects/*.yaml` are dropped), and is served as a Workers Asset with `Content-Disposition: attachment; filename="openspec-flat.zip"` and `Cache-Control: public, max-age=300, stale-while-revalidate=86400` (set in `apps/web/next.config.ts § headers()`). Cross-subdomain download from `app.rapoport.studio` works because the server header is the gate, not the `download` attribute hint.

Source: `openspec/archive/2026-05-21-add-openspec-context-archive/`.

---

## D-SP-3 ratification — right-rail (Change · Spec · Code) composition (applied 2026-05-20 from change `apply-d-sp-3-right-rail`, RAP-1136)

The `RAIL_LABEL = 'Changes'` export contract from `archive/2026-05-20-spec-chat-surface-redesign-d1-editorial` is **superseded by D-SP-3**. The right rail carries three vertical blocks (Change · Spec · Code), each with its own caption header; no top-level rail label. Empty-state banners render inside the relevant block, below the tab strip (see § Spec page surface table). The vestigial `RAIL_LABEL` export was removed from `apps/studio/src/app/(auth-walled)/(spec)/spec/[id]/artifact-panel.tsx`; a regression test in `artifact-panel.test.tsx` guards against re-introduction.

Source: `openspec/archive/2026-05-20-apply-d-sp-3-right-rail/`.

---

## Canvas chat — action-workflow actor module (applied 2026-05-20 from change `extract-actions-machine`)

The non-turn action wrappers folded into `chatMachine` per the Wave 2A topology (see `§ Canvas conversation lifecycle FSM` for the 2026-05-09 shipped surface) now ship their `serverActionActor(...)` wrappers and action-domain types in a sibling module `apps/studio/src/app/(auth-walled)/(spec)/spec/[id]/machines/actions.machine.ts`. The state nodes themselves stay inline in `chat.machine.ts`; runtime topology is unchanged. See `state.md § Action-workflow actors` for the current exported actor surface, the pattern contract, and the promotion trigger.

Source: `openspec/archive/2026-05-20-extract-actions-machine/`.

---

## Unbind registered in the project registry (applied 2026-05-20 from change `register-unbind-in-studio-registry`)

`/projects/unbind` resolves for project owners and members. The studio carries one `public.projects` row (`slug='unbind'`, id `7af9ffd5-8295-4234-8325-68824d1796b8`), one `public.project_members` owner row (Pavel), and two `public.project_connections` rows: `kind='linear'` (team `Unbind`, key `UNB`) and `kind='github'` (`rapoport-studio/unbind.life`). The bindings card on `/projects/unbind` renders both connections.

This completes the unexecuted Wave 1 of `migrate-unbind-into-studio` (archived 2026-05-13 with all waves operator-blocked); the prescribed `20260507100000_register_unbind_project.sql` migration was unappliable (dropped columns + missing `i-avocat` seed) and superseded by direct rows on the current three-table schema (D-RUR-1, D-RUR-2). The stale migration — plus `20260513070000_register_senzo_project.sql` which shares the defect — stays out of scope per Q-RUR-1.

Source: `openspec/archive/2026-05-20-register-unbind-in-studio-registry/`.


## Idea-to-Forge loop closure (applied 2026-05-24 from change complete-idea-to-forge-loop)

**RAP-1202 epic.** The workspace's per-idea surface (`/spec/[id]`) now reflects the full lifecycle of a change end-to-end:

1. **Authoring (`captured → discussing`).** Architect mode emits four artefacts via four emit tools — `update_proposal / update_design / update_tasks / update_verification` (the last added by RAP-1205). The Change-block renders four tabs in canonical order. The empty-state gate ("All four artifacts required") and `convertToProjectAction`'s pre-flight check (returns `missing_verification` when three are accepted but verification.md is missing) keep the four-of-four contract honest.
2. **Dispatch (`discussing → drafted → branched`).** Files accumulate at `status='accepted'`; `convertToProjectAction` writes the change folder branch + tree commit, transitions files to `status='applied'`, and stamps `ideas.{github_branch, github_repo_full_name, converted_to_project_at, base_sha_at_conversion}`. `openPullRequestAction` opens the PR and stamps `ideas.last_pr_url`. The same flow is callable from the `muse draft` CLI via `--convert` / `--open-pr` (RAP-1206).
3. **Forge build (`drafted → applied`).** PR merge to main triggers `.github/workflows/forge-on-change-merge.yml` (RAP-1207), which dispatches `forge-build.yml` and PATCHes `ideas.forge_state_url` + `forge_last_checkpoint='plan-ack'`. The studio's `pollBuildStateAction` (RAP-1208) polls the GitHub Actions Run API on a 15s interval (gated on `forgeStateUrl !== null` + `forgeOutcome === null` + tab visible + view=edit), PATCHing `forge_last_checkpoint` for in-flight checkpoints and `forge_outcome` on terminal. The Change-block's Build sub-section + the change-timeline rail status pill both bind to `resolveForgeBuildRole(checkpoint, outcome)` from `@rapoport-studio/ui/lib/status-resolver` — one read path, two surfaces (closes D-CCP-6 of `reframe-canvas-change-pipeline`).
4. **Completion (`drafted → applied`).** When the poll observes `forge_outcome = 'completed-with-pr'` AND the code PR at `ideas.last_pr_url` is merged on GitHub, the same PATCH writes `ideas.status = 'applied'` (D-IFL-15; conservative — any error or non-merged PR returns false).

**Substrate columns added to `ideas` for this loop:** `rap_id` (Phase 4a, partial UNIQUE), `forge_state_url`, `forge_last_checkpoint`, `forge_outcome` (Phase 2). See `openspec/current/db/entities.md § Forge build-state columns on ideas`.

Source: `openspec/archive/2026-05-24-complete-idea-to-forge-loop/`.


## Deploy-state pill (applied 2026-05-25 from change `add-deploy-state-header-pill`)

The studio shell carries a small **deploy-state pill** that surfaces the current production deploy state at a glance. Mounted in `<StudioSidebar />`'s `SidebarFooter` above `<UserMenu>` — `apps/studio` has no top header, so the pill adapts the design's "top-right before user avatar" guidance to the sidebar shell (near the user-menu avatar). The pill primitive lives at `apps/studio/src/components/deploy-state-pill/`.

**Four visual states** rendered as an 8-px coloured dot + short uppercase label (label hidden under 480 px; collapsed icon-only mobile variant):

1. `in_progress` (blueprint, animate-pulse) — production deploy is queued or running. Optional `commitsBehind` sub-label `— N behind` rendered inline when main HEAD is ahead of the running commit.
2. `success` (success/green) — last completed run on `main` succeeded.
3. `failure` (error/red) — last completed run on `main` failed; clicks open the run page in GitHub.
4. `unknown` (ink-muted/gray) — API error OR no completed runs in the 20-run lookback window.

**Server Action contract.** `pollDeployStateAction()` (`apps/studio/src/components/deploy-state-pill/poll-deploy-state.ts`) reads the studio production workflow's last 20 runs via the GitHub Actions REST API using the existing `GITHUB_BOT_TOKEN` (resolved by `readGitHubBotToken()` — the same helper that powers `pollBuildStateAction` from RAP-1208). The token never leaves the Worker. All operator tabs share the single 5000/h PAT budget (D-DSHP-2 server-side pivot).

The Server-Action contract returns `{ ok: true, state }` or `{ ok: false, error: 'github_api_error' | 'github_token_unavailable' }` — sync helpers (workflow-ID resolver + cache) live in the sibling `poll-deploy-state.helpers.ts` module so the `"use server"` file only exports async functions (the Phase-6 `poll-build-state` lesson).

**Derivation contract (`deriveState`).** Iterative scan, newest-first, ignores `cancelled` / `skipped` runs (concurrency-supersede chain); stops at the first meaningful conclusion (`in_progress` / `queued` / `success` / `failure`). The 20-run window guards against long supersede chains. `commitsBehind` is computed lazily via a callback that calls `GET /repos/.../compare/<a>...<b>` only when `in_progress` — the callback may throw, in which case the count defaults to 0 (no sub-label) rather than escalating to `unknown`.

**Client hook (`useDeployState`).** Mount + every 30 s while `document.visibilityState === 'visible'` + on `window.focus`. Unmount cancels the interval and the in-flight setState via a `cancelled` flag.

**Workflow-ID resolution.** `STUDIO_DEPLOY_WORKFLOW = 'Deploy studio (production)'` is resolved via `GET /repos/.../actions/workflows` on first call and cached at module scope per Worker instance. Workflow rename = cache miss on next poll = self-healing re-resolve.

Source: `openspec/archive/2026-05-25-add-deploy-state-header-pill/`.

---

## D-SP-3 superseded — see D-RRR-1..13 (applied 2026-05-26 from change `redesign-spec-right-rail`)

The right-rail three-block (Change · Spec · Code) composition ratified in D-SP-3 is **superseded by D-RRR-1..13**. The rail collapses to a single Change block; Spec and Code demote to a 44 px icon dock + drawer overlays that never restructure the rail. Citations panel surfaces what the Architect just read. URL contract narrows to `?rail=change` (only canonical) + `?drawer=spec|code` (mutually exclusive); a 30-day client-side redirect maps legacy `?rail=spec|code|change,spec,code` URLs through the `spec_page.url_state.legacy_rail_redirect` telemetry event until ≈ 2026-06-25 (D-RRR-9).

`D-RRR-10..13` extend the contract with: drawer width fixed at 480 px (variable fallback past 30 % compact-preset usage); citations panel persistent hairline at rest (heatmap fallback to conditional rendering); stage-stepper interactivity folds into Phase 1 (split fallback to `make-stage-stepper-interactive` past wave-budget spillover); popover trigger right-click on desktop + long-press on touch, no shift-click (a11y fallback to `⋯` affordance).

Phase 2 (D3 / Split Card resting state, Wave 6 of the change) is deferred behind RAP-1101 R5.

Source: `openspec/archive/2026-05-26-redesign-spec-right-rail/`.

---

## D-SP-7 stepper — position + interactivity amendment (applied 2026-05-26 from change `redesign-spec-right-rail`)

`<StageProgressView>`'s data binding contract on `CanvasStageView` and `useCanvasStage` is **preserved verbatim** — D-SP-7's stepper computation, persona-hint mapping, and Realtime fallback semantics unchanged. Position and interactivity change per D-RRR-2:

- **Position**: moves out of `<CanvasHeaderStrip>`'s right-action cluster into a new `<StageStepperRow>` immediately under the header strip, full content-width, above the three-column body. The right-action cluster ordering becomes: role pill + kebab (D-SP-1 preserved minus the stepper position).
- **Interactivity**: each stage marker is left-click → jumps to its anchor (Captured → chat filter; Discussing → chat thread; Drafted → rail's `<ArtefactTabs>` switches to latest-edit file; Applied → `verification.md` + `<ModeSwitchStrip>` flips to `diff`; Promoted → chat shows merged-PR receipt). Right-click on desktop + long-press on touch surfaces the existing popover-on-click breakdown (D-RRR-13 — shift-click explicitly rejected).

Source: `openspec/archive/2026-05-26-redesign-spec-right-rail/`.

---

## D-RRR-14 — Drawer overlay geometry (applied 2026-05-26 from change `redesign-spec-right-rail`)

Ratified retroactively against the shipped `<SpecDrawer>` and `<CodeDrawer>` implementations under `apps/studio/src/app/(auth-walled)/(spec)/spec/[id]/components/change-rail/`. The geometry was decided by the shipping code; D-RRR-14 is its written form so future audits can verify the contract directly without spelunking. Pairs with D-RRR-4 (icon-dock + drawer affordance) and D-RRR-10 (drawer width = 480 px fixed).

**Positioning.** Drawers are fixed-position overlays anchored to the rail's inner edge — they do **not** restructure the page (the rail stays full-height behind them). The exact Tailwind / inline-style pattern is identical in both drawer components:

```tsx
className="fixed inset-y-0 flex w-[480px] flex-col overflow-hidden border-l bg-canvas-dark shadow-xl"
style={{ right: "var(--rail-w)" }}
```

`var(--rail-w)` is the resizable rail-width CSS variable from RAP-1118. The drawer's right edge tracks the rail's inner edge as the user drags the rail boundary; no separate state syncing required.

**Dismissal.** Two paths, both must be wired by every drawer:

- **Outside-click** — `pointerdown` outside the drawer's root ref closes the drawer (via the existing URL-state writer that drops `?drawer=`).
- **`Esc` key** — `keydown` listener on `window` while the drawer is mounted; first key press closes the drawer.

Both handlers are duplicated identically in `spec-drawer.tsx` and `code-drawer.tsx` (the duplication is intentional — two ~15-line drawer components, no need to extract a hook for a pair). Citations:

- `apps/studio/src/app/(auth-walled)/(spec)/spec/[id]/components/change-rail/spec-drawer.tsx:37-38` — positioning markup.
- `apps/studio/src/app/(auth-walled)/(spec)/spec/[id]/components/change-rail/spec-drawer.tsx:14, 20` — outside-click + Esc handler comments + implementation.
- `apps/studio/src/app/(auth-walled)/(spec)/spec/[id]/components/change-rail/code-drawer.tsx` — same pattern, verified identical.

**Inherits.** Drawer-open URL state from D-RRR-9 (`?drawer=spec|code`, mutually exclusive); drawer-width literal from D-RRR-10 (480 px fixed, with the same falsifier on compact-preset usage > 30 % triggering a switch to the variable formula).

**Note.** This decision was not in the original wireframe pass that produced D-RRR-1..9; the geometry was ratified by the shipped code during RAP-1228 Wave 3 implementation. Reading it back into the design canon now closes the documentation gap without altering shipped behaviour.

Source: `openspec/archive/2026-05-26-redesign-spec-right-rail/`.

---

## D-SPV-2 — Stage stepper corner brackets (applied 2026-05-27 from change `polish-spec-page-visual-fidelity`)

Refines D-RRR-2 (stage spine). The active stage's `<button>` in `<StageStepperRow>` (`apps/studio/src/app/(auth-walled)/(spec)/spec/[id]/components/stage-stepper-row.tsx`) is wrapped externally in `<TacticalFrame size="sm" tone="primary" animated staggerIndex={idx}>` from `packages/ui/src/components/tactical-frame.tsx`. Inactive stages render bare `<button>`. The frame draws four corner L-brackets (~1 px terracotta, arm 12 px) and deploys via the existing `tactical-bracket-deploy` keyframe — `prefers-reduced-motion: reduce` collapses the deploy to instant via the global `--duration-scalar`.

**Tone selection.** `tone="primary"` produces terracotta brackets because the studio's `globals.css` carries the shadcn slot inversion documented at `openspec/current/design-tokens.md § Reconciled mapping`: brand-canonical `--color-accent` (terracotta) lives in the `--primary` slot. `tone="accent"` would render blueprint blue.

**Why external wrap, not inline spans.** `<TacticalFrame>` renders nested `<div>`s; nesting a `<div>` inside a `<button>` is invalid HTML (rejected by React strict mode + screen readers). The wrap is therefore *around* the button. Flex parent's `gap-4` still applies because TacticalFrame's outer div becomes the flex item for the active stage. Fallback path (inline `<span data-corner>`) is documented for future regressions but was not needed — the wrap shipped cleanly.

**Q-SPV-1 resolved.** `<TacticalFrame>` was already lifted to `packages/ui` long before this change with ≥ 5 prior consumers (`entity-card`, `chart-container`, `builds-kpi-strip`, `ChatPrimitive`, `throughput-client`). The stage stepper is consumer 6; the Wave 4 Preview button is consumer 7. No new lift required.

Source: `openspec/archive/2026-05-27-polish-spec-page-visual-fidelity/`.

---

## D-SPV-3 — Citations panel resting state strengthened (applied 2026-05-27 from change `polish-spec-page-visual-fidelity`)

Refines D-RRR-3 (citations panel). The non-empty branch of `<CitationsPanel>` (`apps/studio/src/app/(auth-walled)/(spec)/spec/[id]/components/change-rail/citations-panel.tsx:40-68`) gains a blueprint-tinted background (`color-mix(in oklch, var(--color-blueprint) 3%, transparent)`), an eyebrow row (`↳ Architect cited · <hh:mm>` left, `N of 4` right), and two-column rows (path + location, dashed blueprint-tint separator between rows; not after the last). The empty branch (lines 29-38) ships verbatim — 6 px ink-muted hairline preserved.

**`CitationRow.cited_at` data flow.** `CitationRow` (`apps/studio/src/app/actions/citations.helpers.ts`) gains `cited_at: string | null` per row. `getCitationsAction` (`apps/studio/src/app/actions/citations.ts:29`) extends the `.select(...)` on `idea_tool_uses` with `completed_at` and maps to per-row `cited_at`. No new join, no RLS change. The eyebrow timestamp is `max(cited_at)` across the rendered rows, formatted in the local viewer's timezone via `toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false })`. The aggregation uses an explicit `r.cited_at !== null` filter before `Math.max` to avoid the `+new Date(null) === 0` epoch-trap (a naive `.filter(Number.isFinite)` would render epoch (01:00 / 03:00) instead of dropping the suffix).

**Q-SPV-2 remains open.** When `bring-chat-artifact-embeds-under-mediablock` archives and `Q-RRR-5` chooses Option A (replace `<ModeSwitchStrip>` with the shared `<MediaBlock.ModeSwitch>` primitive), the D-SPV-1 typography audit must be re-run against the shared primitive. Tracked against the follow-up `consolidate-rail-mode-switch-onto-mediablock` change's verification gate.

Source: `openspec/archive/2026-05-27-polish-spec-page-visual-fidelity/`.

---

## D-SPV-4 — Footer CTA lifted from `<ArtifactPanel>` into `<ChangeFooter>` (applied 2026-05-27 from change `polish-spec-page-visual-fidelity`)

Refines D-RRR-1 (single Change block). The CTA block (Preview · Promote · caption) lifts from `<ArtifactPanel>` (`apps/studio/src/app/(auth-walled)/(spec)/spec/[id]/artifact-panel.tsx`, lines 138-199 deleted) into `<ChangeFooter>` (`apps/studio/src/app/(auth-walled)/(spec)/spec/[id]/components/change-rail/change-footer.tsx`, was an 8-line stub returning `null`). `<ChangeRail>` (`apps/studio/.../change-rail.tsx`) renders `<ChangeFooter>` after `<CitationsPanel>` inside the single-block flex column and threads `{status, pending, hasFullTriplet, onPromote, canPreview, onPreview, buildState, artifactCount}` directly from the rail (skipping `<ArtifactPanel>`).

**Collapse to two stacked elements.** The three-peer stack (`[Preview applied change]` + caption + `[Promote to /openspec/changes]`) is collapsed to:
1. A tactical-bracketed Preview verb (rendered only when `canPreview === true`) — uses `<TacticalFrame size="sm" tone="primary">` (TacticalFrame consumer #7 — see D-SPV-2).
2. A single primary Promote button — ink-on-canvas when `hasFullTriplet === true`; disabled with `Promote (all 4 required)` copy otherwise.
3. A single mono caption row underneath — `N of 4 artefacts drafted · forge: <state>`.

**Empty state.** `<ChangeFooter>` returns `null` when `!hasFullTriplet && artifactCount === 0` — the rail does not promote-prompt when there is nothing to promote.

**Consumer audit.** `<ArtifactPanel>` is consumed only by `<ChangeRail>` in production; the test file `artifact-panel.test.tsx` was updated alongside.

Source: `openspec/archive/2026-05-27-polish-spec-page-visual-fidelity/`.

---

## D-SPV-5 — Title identifier chip (applied 2026-05-27 from change `polish-spec-page-visual-fidelity`)

Refines D-SP-1 (header strip identity cluster) and D-SP-7 stepper amendment (title rendering surfaces). A new `<ParsedTitle>` helper (`apps/studio/src/components/parsed-title.tsx`) chunks snake_case identifiers out of title strings and wraps each match in inline `<code>` chips (`font-mono text-[0.88em] bg-[var(--color-code-bg)] rounded-[3px] px-1 py-px`). Surrounding prose stays as serif spans inside the outer wrapper (default `font-serif text-sm leading-tight`). The chip's tone is *quieter* than the serif surround — emphasis through contrast per design-system § Editorial recession.

**Detection heuristic.** `IDENTIFIER_RE = /\b([a-z_][a-z0-9_]{3,})\b/g`. A token is an identifier iff it matches the regex AND (contains `_` OR is in the curated keyword set). `CURATED_KEYWORDS` ships empty — pure snake_case detection. Precision is high (every snake_case token is, by construction, an identifier); recall is bounded (single-word identifiers like `middleware` or `RLS` don't chip). False positives surface via the `data-title-warn` attribute on the outer wrapper, which QA inspects periodically.

**Callsites.** Two wiring sites:
1. `apps/studio/src/components/inline-edit-title.tsx` — display-mode renders `<ParsedTitle title={saved}>` directly. Primary user-visible path on the spec page (the editable idea title in `<HeaderStrip>`).
2. `apps/studio/src/app/(auth-walled)/(spec)/spec/[id]/views/chat-view.tsx` — fallback path. When the upstream Server Component does not compose a `titleSlot` (read-only render paths), `<HeaderStrip>`'s `titleSlot` is set to `<ParsedTitle title={specTitle} />`.

`<HeaderStrip>` itself is unchanged — its `titleSlot: ReactNode` contract was already permissive enough to accept the chip wrapper.

**Amendment (applied 2026-05-27 from change `fix-rap-1240-parsed-title-server-render-crash`).** The original wave shipped a `renderValue?: (value: string) => ReactNode` render-prop on `<InlineEditTitle>` and composed `<ParsedTitle>` in `page.tsx`. That pattern crashed the spec page in production (Next.js Server Component error digest `677038648`) because `renderValue`, an arrow function, is not serialisable across the Server → Client component boundary. The fix moves `<ParsedTitle>` to `apps/studio/src/components/parsed-title.tsx` (shared location), has `<InlineEditTitle>` import + render it directly, and drops the `renderValue` prop. `page.tsx` no longer references `<ParsedTitle>` — it composes `<InlineEditTitle>` as a plain JSX node and the chip behaviour rides along inside the client component. The visible behaviour is identical; the SSR boundary is no longer crossed by a function prop.

Source: `openspec/archive/2026-05-27-polish-spec-page-visual-fidelity/`.

---

## D-URC-1..6 — Unify change rail mode toggle and MediaBlock chrome (applied 2026-05-29 from change `unify-change-rail-mode-and-chrome`)

Refines D-RRR-1 (single Change block) and D-RRR-9 (URL = state of UI). The change rail's chrome collapses from four horizontal strips to two: a working caption row (caption + `<ModeSwitchStrip>` + `<CopyArtifactButton>`) followed by the artefact tabs. `MediaBlock` inside `<ArtifactPanel>` renders with the new `chrome="none"` prop so its filename header, mode toggle, Copy, and Download disappear; the body sits edge-to-edge inside `TabsContent`'s `px-5 py-5` padding.

**D-URC-1 — Single mode toggle, merged into caption row.** `ModeSwitchStrip` is no longer its own strip. The component (`apps/studio/src/app/(auth-walled)/(spec)/spec/[id]/components/change-rail/mode-switch-strip.tsx`) was stripped to a bare segmented control (`role="group"` + three buttons) and inlined into the caption row as the right-of-center child. The `data-slot="mode-switch-strip"` attribute is preserved for the Wave 3 regression test. The previously-dead `modeByTab` uncontrolled state was deleted — `<ChangeRail>` has always passed both `mode` and `onModeChange`, so the URL (`?mode=`) is the sole authority.

**D-URC-2 — Filename lives only in tabs.** The duplicate `MediaBlock` filename header is suppressed via `chrome="none"`. The active tab is the only place the artefact name appears.

**D-URC-3 — Copy as contextual icon.** New `<CopyArtifactButton>` (`apps/studio/src/app/(auth-walled)/(spec)/spec/[id]/components/change-rail/copy-artifact-button.tsx`) replaces the `MediaBlock` Copy/Download row in this surface. Lucide `Clipboard` icon, no text label. `aria-label`/`title` name the active tab and include a `(source)` / `(diff)` suffix on those modes so the user knows what mode-form will land in clipboard. On success: lucide `Check` icon + `announce(...)` to the polite ARIA live region for 1500 ms.

**D-URC-4 — Download removed.** No replacement, no overflow re-home. `?mode=source` + browser save-as covers the local-file fallback; the canonical source for applied changes is the GitHub PR. If a future surface ever needs it, it moves to the outer page header's overflow `⋯`, not back into the rail.

**D-URC-5 — `MediaBlock` `chrome` prop.** `packages/ui/src/components/media-block/media-block.tsx` gains `chrome?: "full" | "minimal" | "none"` (default `"full"` — all existing consumers unchanged) and controlled `mode?: ViewMode` + `onModeChange?` props. `chrome="none"` suppresses the outer `border`/`bg`, the Header (Title + ModeSwitch + Actions), and the Footer. `chrome="minimal"` is reserved (Title-only Header, no actions) for a future chat-embed surface; not consumed today. `MediaBlockContext` exposes `chrome` so Body can react to it.

**D-URC-6 — Edge-to-edge body, single padding.** When `chrome="none"`, `Body` (`packages/ui/src/components/media-block/body.tsx`) drops its `p-3` so the parent container's padding becomes the sole padding around the rendered content. In `<ArtifactPanel>` that parent is `TabsContent` with `px-5 py-5` — the markdown sits flush against the tab interior, no nested-box visual.

**Relationship to Q-SPV-2.** Q-SPV-2 anticipated a future `consolidate-rail-mode-switch-onto-mediablock` change that would replace `<ModeSwitchStrip>` with the shared `<MediaBlock.ModeSwitch>` primitive (Q-RRR-5 Option A). That change is **superseded by D-URC-1..6**. The two switches are kept distinct on purpose: `<MediaBlock.ModeSwitch>` is artifact-level (context-driven, gates on `baseContent` for the diff button), `<ModeSwitchStrip>` is change-level (URL-driven, always shows all three modes because diff is a change-level concept). Conflating them would re-introduce the boundary problem D-URC-1 just resolved. The D-SPV-1 typography audit re-run that Q-SPV-2 tracked is satisfied here: `<ModeSwitchStrip>` button labels are explicitly `font-mono text-[10px] uppercase tracking-[0.18em]` (D-URC-1 implementation), matching the caption ritual.

**Q-URC-2 / Q-URC-3 default resolutions.** Narrow-rail fallback (caption + segmented + copy overflow) and copy-feedback shape (toast vs inline) were left default-resolved by the implementation: no width-based fallback yet (caption shrinks naturally via flex), inline-only feedback (icon swap + ARIA announce, no toast). Both remain observable from production telemetry; revisit if the caption row breaks below a real rail width or copy events surface confusion.

Source: `openspec/archive/2026-05-29-unify-change-rail-mode-and-chrome/`.
