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

---
title: Studio
status: stable
owner: pavel
audience: [pavel, ai, engineer]
tldr: Auth-walled workspace at app.rapoport.studio — admin surfaces for managing the studio operations, content, and infrastructure.
---
# Studio

> Auth-walled workspace at `app.rapoport.studio`. Internal tooling for Pavel to manage the studio's operations, content, and infrastructure.

---

## Admin surfaces

### `/work` admin

Three auth-walled routes under `(auth-walled)/studio/work/`:

- `/studio/work` — list view: renders every `work_engagements` row as `<EntityCard variant="row">` with columns slug, title, status chip, year, stage chip, and an "Edit" link.
- `/studio/work/new` — create form bound to `createWorkEngagement`.
- `/studio/work/[id]/edit` — edit form pre-populated from the `work_engagements` row; same field coverage as create; Delete and Publish rendered as separate action buttons.

**Server actions** (`apps/studio/src/app/(auth-walled)/studio/work/_actions.ts`):

| Action | Behaviour |
|---|---|
| `createWorkEngagement(input)` | Validates via `workEngagementInputSchema`, inserts row, calls `revalidateTag('work-engagements')`. |
| `updateWorkEngagement(id, input)` | Partial update via `workEngagementInputSchema.partial()`, calls `revalidateTag('work-engagements')`. |
| `deleteWorkEngagement(id)` | Deletes row, calls `revalidateTag('work-engagements')`. |
| `publishWorkEngagement(id)` | Calls `publish_work_engagement` Postgres function (sets `status='published'`, preserves existing `published_at` via `coalesce`), calls `revalidateTag('work-engagements')`. |

**Auth gate:** the `(auth-walled)` route group enforces session presence at the routing layer. Each server action additionally calls `assertPavel()` — a server-side re-check that the authenticated user's `auth.uid()` matches `PAVEL_UID` (Infisical-injected env var) — providing defense in depth against any route-group bypass. `assertPavel` is the single enforcement point; it is not skipped on any code path.

**Form coverage:** every column in the `work_engagements` schema. `jsonb` fields (`timeline`, `links`) edit as monospace textareas with client-side Zod validation on submit; invalid JSON triggers an inline error without resetting the form. Structured sub-form inputs for `timeline` / `links` are a future enhancement.

**Revalidation contract:** every mutating action calls `revalidateTag(WORK_TAG)` (`"work-engagements"`) on success. The constant is imported from `@rapoport-studio/db` — no inline string drift between loader and actions.

**Out of scope (deferred):** live preview, drag-drop reorder, autocomplete stack picker, image upload pipeline for `hero`, multi-admin expansion (Misha / future hires).

Source: `archive/2026-05-16-migrate-work-content-to-supabase/design.md § 6`.


## OpenSpec ingest for client projects (applied 2026-05-25 from change `lift-spec-block-and-ingest-linked-repo-openspec`)

**RAP-1223.** The studio renders OpenSpec for non-studio projects by pulling the linked GitHub repo's `openspec/current/` tree into a per-project snapshot table. Read-only; edits stay in the client repo through its own change-quad flow. The `STUDIO_PROJECT_SLUG` constant in `apps/studio/src/components/canvas/spec-block.tsx` survives as the discriminator between the build-time bundle (studio = `OPENSPEC_BUNDLE`) and the runtime snapshot (every other project = `project_openspec_snapshots` rows). See `current/spec-preview.md § SpecBlock data sources` for the rendering contract.

**Server actions** (`apps/studio/src/app/actions/`):

| Action | Behaviour |
|---|---|
| `syncProjectOpenspec(projectId)` | Owner-only via `requireOwner()`. Calls `GitHubClient.getTree` + `getFileContent` (sanctioned `@rapoport-studio/forge/core` subpath), upserts each `*.md` blob under `(project_id, path)`, prunes orphans, writes `projects.openspec_ingest_last_synced_at`. KV-backed rate limit (10/hr per project, 100/hr global). |
| `fetchProjectSnapshot(projectId)` | Read-only loader for `<ProjectSnapshotSpecBlock>`. Returns `{ files, lastSyncedAt, lastError }`. |
| `clearProjectOpenspecSnapshots(projectId)` | Deletes all rows for the project. Invoked from the settings UI when ingest is disabled (with confirmation prompt). |
| `updateProjectOpenspecIngest(projectId, input)` | Settings mutation: toggle `openspec_ingest_enabled`, set `_repo` / `_branch`. |

**Settings UI** (`apps/studio/src/app/(auth-walled)/(default)/projects/[slug]/settings/`):

`OpenspecIngestSettings` component renders the opt-in toggle + repo + branch inputs + "Sync now" button + last-synced status + last-error surface. Disable prompts confirmation and runs `clearProjectOpenspecSnapshots` before flipping the flag. Default `openspec_ingest_repo` reads from the project's connected GitHub repo if populated.

**Design adaptations from the spec.** The originally-drafted `auth_org_id()` RLS pattern is unavailable in the studio (no org-scoped auth helper); the shipped RLS uses `owner_id = auth.uid() OR is_project_member(project_id)`. The `withAuth` / `withRole` wrappers from the vivod precedent are not the studio's pattern either — `requireOwner()` is the native gate. TanStack Query was replaced with `useState` / `useEffect` for the snapshot fetch since this slice of the studio does not use TanStack Query. `gh.getBlob` became `getFileContent(branch, path)` per the studio's `GitHubClient` API. Six ingest columns landed on `projects` (not five — `_last_error_at` was added for surfacing in the settings UI alongside `_last_error`).

**Rate limit** (`apps/studio/src/lib/openspec-sync-rate-limit.ts`): Cloudflare KV-backed via the `NEXT_INC_CACHE_KV` binding. Per-project counter `openspec-sync:{projectId}` (10/hr); global counter `openspec-sync:global` (100/hr). Failed syncs do not consume quota (increment after success).

**Boundary**: the studio reads but does not write to client repos. Editing a client project's openspec lives in the client's own repo through its own change-quad flow. Cross-repo search and Muse-context injection are deferred to follow-up changes.

Source: `openspec/archive/2026-05-25-lift-spec-block-and-ingest-linked-repo-openspec/`.

---

## Outbound prompt secret redaction (applied 2026-05-26 from change `wrap-studio-anthropic-with-cli-core-security`)

Every Anthropic SDK client constructed inside `apps/studio` is built via [`apps/studio/src/lib/anthropic-client.ts § createScrubbedAnthropic`](../../apps/studio/src/lib/anthropic-client.ts), which wraps the underlying `new Anthropic({ apiKey })` with `wrapAnthropicWithSecretScrub` from `@rapoport-studio/cli-core/security`. The factory is the single chokepoint; direct `new Anthropic(...)` inside `apps/studio/src/` is forbidden and grep-verifiable (`rg 'new Anthropic\(\{' apps/studio/src` returns only the factory file itself).

Coverage — five call sites refactored:

| Surface | File | Discriminator |
|---|---|---|
| Canvas turn | `apps/studio/src/app/(auth-walled)/(spec)/spec/[id]/turn/route.ts` | `cli: 'muse'` / `role: 'canvas-turn'` |
| Telegram canvas-bot wake classifier | `apps/studio/src/app/api/studio/telegram/webhook/route.ts` | `cli: 'muse'` / `role: 'tg-classifier'` |
| Telegram Pavel-bot command handler | `apps/studio/src/app/api/studio/command/route.ts` | `cli: 'muse'` / `role: 'tg-command'` |
| Forge planner (studio-side) | `apps/studio/src/lib/forge/run-plan.ts` | `cli: 'forge'` / `role: 'planner'` |
| Files-upload client | `apps/studio/src/lib/files/files-client.ts` | `cli: 'muse'` / `role: 'files-upload'` |

Per `D-WSAS-2` the `cli` discriminator is **per-runtime, not per-route** — canvas turn + Telegram surfaces + files-upload share `'muse'` (they're Muse-flavored work); only the Forge planner carries `'forge'`. Adding a new `'studio'` enum value to `cli-core/events/schema.ts § EventEnvelopeSchema.cli` is `D-WSAS-3`-deferred (schema change with CI-validated downstream aggregators; not justified for v1).

**Package boundary update.** `apps/studio` gains direct dependency on `@rapoport-studio/cli-core` (previously only transitive via Muse). The CLAUDE.md `§ Package boundaries` row for `apps/studio` now includes `@rapoport-studio/cli-core/security` alongside the existing `ui, db, i18n, canvas (deferred), muse (deferred), forge/core` allowlist. This is incremental — the existing transitive-via-Muse dep already exists; this PR makes the import explicit.

**Threat closed (third half): AT-07** in [`openspec/_methodology/security/agents-threat-model.md`](../_methodology/security/agents-threat-model.md) — Forge half shipped PR #1792, Muse half shipped PR #1810, apps/studio half ships in this change. Source-of-truth: [`openspec/archive/2026-05-26-wrap-studio-anthropic-with-cli-core-security/`](../archive/2026-05-26-wrap-studio-anthropic-with-cli-core-security/).

## Idea + spec titles (applied 2026-05-26 from change `add-title-to-ideas-and-specs`, RAP-1226)

Both `specs` and `ideas` carry a `title text` field used as the display name in the studio's headers + sidebar. Write paths:

- `specs.title` — defaults to `projects.name` on insert; backfilled for existing rows (one-shot SQL in `supabase/migrations/20260525230000_rap_1226_add_ideas_title_and_backfill_specs_title.sql`). Editable via `updateSpecTitle(specId, title)` (owner-only).
- `ideas.title` — populated by Muse Architect alongside `update_proposal` (derived from the proposal's H1 or first sentence; verb-led, ≤ 140 chars). Editable via `updateIdeaTitle(ideaId, title)` (owner-only). Falls back to `idea.slug` when null.

UI surfaces: spec header, idea header (inside spec view), idea sidebar row. Inline-edit via the `<InlineEditTitle>` component at `apps/studio/src/components/inline-edit-title.tsx` (save-on-blur / save-on-Enter / cancel-on-Escape, 140-char soft cap, non-owners see plain text). Renaming a title does NOT change `ideas.slug` or `ideas.github_branch` (slug stability per D-IT-2). Source: [`openspec/archive/2026-05-26-add-title-to-ideas-and-specs/`](../archive/2026-05-26-add-title-to-ideas-and-specs/).
