# App Creation & CRUD — Developer Specification

> **Purpose:** Single reference for implementing and reviewing portal features around **apps**, **personas**, **config snapshots**, and **deployments**.  
> **Companion docs:** [HLD.md](./HLD.md) · [plan.md](./plan.md) · [flow.md](./flow.md) · [Flow A diagram](./flows/flow-a-deploy.mmd) · [Interactive mockup](./admin-operator-mockup.html)

---

## 1. Scope

| In scope | Out of scope (v1) |
|----------|-------------------|
| Create / read / update / archive **apps** | Custom code in portal |
| CRUD **personas** (prerequisite for apps) | Self-serve end-user signup |
| Immutable **config snapshots** per deploy | Billing/quota engine |
| **Deploy**, rollback, Sight toggle | Replacing Cloud Build |
| **deployment_events** audit trail | Per-app Firestore DB split |

**Roles**

| Role | App CRUD | Persona CRUD | Admin APIs |
|------|----------|--------------|------------|
| `developer` | Yes (own apps or all — TBD policy) | Yes | No |
| `admin` | Yes + oversight all apps | Yes | Yes (`/api/admin/*`) |

---

## 2. Core concepts

| Concept | ID | Mutable? | Notes |
|---------|-----|----------|--------|
| **Persona** | `persona_id` (slug) | Yes (library) | Shared prompt bundle; apps reference by ID |
| **App** | `app_id` (= slug in v1) | Config yes; ID no | One Cloud Run service `rag-{app_id}` |
| **Config snapshot** | `snapshot_id` | **Immutable** | Revision points here; live `apps/{id}` can drift until redeploy |
| **Deployment** | `build_id` + revision | Append-only events | Recorded in `deployment_events` |
| **Sight** | `sight_enabled` on app | Hot-updatable | Kill-switch; 503 when off |

**URL rule:** `https://{slug}.apps.yourdomain.com` where `slug` === `app_id` (v1).

---

## 3. Entity relationships

```text
personas/{persona_id}
       │
       │ persona_id (FK)
       ▼
apps/{app_id} ──────────────┬──► config_snapshots/{snapshot_id}  (immutable)
       │                    │
       │                    └──► deployment_events/{event_id}
       │
       ├──► secrets_meta/{app_id}/keys/{key_name}  → GSM
       └──► Cloud Run: rag-{app_id}
```

**Rule:** Creating an app **requires** an existing `persona_id` (or create persona first — see mockup wizard).

---

## 4. CRUD matrix (requirements)

### 4.1 Personas

| Operation | API | Firestore | Side effects | UI |
|-----------|-----|---------|--------------|-----|
| **Create** | `POST /api/personas` | `personas/{id}` | — | `/personas/new` or wizard step |
| **Read list** | `GET /api/personas` | query all | — | `/personas` |
| **Read one** | `GET /api/personas/{id}` | get | — | Persona detail |
| **Update** | `PATCH /api/personas/{id}` | patch document | Returns `affected_apps[]`; **does not** auto-deploy | Edit form + “Redeploy affected apps?” |
| **Delete** | `DELETE /api/personas/{id}` | soft-delete | **409** if any app references persona | Delete button disabled when in use |

**Persona fields (required for create)**

| Field | Type | Validation |
|-------|------|------------|
| `name` | string | 1–80 chars |
| `id` / `persona_id` | string | `^[a-z0-9-]+$`, unique |
| `system_prompt` | string | non-empty |
| `first_message` | string | non-empty |
| `document_locked_message` | string | non-empty |
| `voice_settings` | object | `tts_voice`, `stt_lang` |
| `default_llm` | object | `model`, `temperature`, optional `reasoning_effort` |

---

### 4.2 Apps

| Operation | API | Firestore | Side effects | UI |
|-----------|-----|---------|--------------|-----|
| **Create** | `POST /api/apps` | `apps/{id}`, first snapshot, secrets meta | GSM secrets, Cloud Build trigger, `deploy_start` event | 4-step wizard → deploy |
| **Read list** | `GET /api/apps` | query + health summary | — | `/apps` |
| **Read one** | `GET /api/apps/{app_id}` | get + optional live health | — | `/apps/{id}` |
| **Update config** | `PATCH /api/apps/{app_id}` | patch `apps/{id}` | **New snapshot optional**; does **not** deploy by default | `/apps/{id}/config` |
| **Deploy** | `POST /api/apps/{app_id}/deploy` | new snapshot + event | Cloud Build (cold path) | Deploy button |
| **Archive** | `DELETE /api/apps/{app_id}` | `status: archived` | Drain traffic, delete Cloud Run (phase 5) | Archive with confirm |
| **Sight toggle** | `POST /api/apps/{app_id}/sight` | patch `sight_enabled` | Hot reload on RAG app | Toggle on detail |
| **Rollback** | `POST /api/apps/{app_id}/rollback` | patch revision + event | Cloud Run traffic 100% → target revision | Deploy tab |

---

## 5. Full app creation flow (happy path)

This is the **primary** flow to implement (maps to **Flow A** and mockup **Create app**).

### 5.1 UI wizard (frontend)

| Step | User input | Client validation |
|------|------------|-----------------|
| 1. Basics | `name`, `slug`, `owner`/`created_by` | slug unique, `^[a-z0-9-]+$` |
| 2. Persona & template | `persona_id`, `template_version`, `sight_enabled` | persona exists |
| 3. LLM & RAG | `llm_config`, `rag_config` | ranges for temp, top_k, min_score |
| 4. Review & deploy | confirm | — |

Optional: `branding`, `knowledge_base_ids`, secret placeholders (admin provides keys).

### 5.2 `POST /api/apps` (backend) — synchronous part

**Request body (conceptual schema):**

```json
{
  "name": "Technician Assistant",
  "slug": "technician",
  "persona_id": "field-technician",
  "persona_overrides": {},
  "template_version": "v1.4.2",
  "llm_config": {
    "model": "gpt-5.4",
    "temperature": 0.2,
    "reasoning_effort": "low",
    "max_tokens": 4096
  },
  "rag_config": {
    "top_k": 8,
    "min_score": 0.6,
    "chunk_size": 800,
    "chunk_overlap": 150,
    "embedding_model": "text-embedding-005",
    "knowledge_base_ids": [],
    "use_reranker": false
  },
  "branding": {
    "logo_url": null,
    "primary_color": "#2563eb",
    "app_title": "Technician Assistant",
    "favicon_url": null
  },
  "sight_enabled": true,
  "deploy": true
}
```

**Backend steps (order matters):**

1. **Auth** — Verify Firebase JWT; resolve `developers/{uid}`.
2. **Validate** — Slug unique; persona exists; template tag exists in registry; configs within bounds.
3. **Identity tenant** — Create Firebase Identity Platform tenant for end users (recommended) → store `identity_tenant_id`.
4. **Firestore `apps/{app_id}`** — `status: "deploying"`, `created_at`, `created_by`.
5. **Snapshot** — Write `config_snapshots/{snapshot_id}` with full config copy; link from app.
6. **Secrets** — Create GSM secrets `openai-{app_id}`, etc.; write `secrets_meta` (no secret values in Firestore).
7. **Event** — `deployment_events`: `deploy_start`, `build_id`, `snapshot_id`, `actor_email`.
8. **Cloud Build** — `triggers.run` with substitutions:
   - `_APP_ID`, `_TEMPLATE_VERSION`, `_CONFIG_SNAPSHOT_ID`, `_SLUG`
9. **Response** — `202 Accepted`:

```json
{
  "success": true,
  "data": {
    "app_id": "technician",
    "status": "deploying",
    "build_id": "abc123",
    "snapshot_id": "snap_20260515_001",
    "poll_url": "/api/apps/technician/deployments/abc123"
  }
}
```

### 5.3 Async pipeline (Cloud Build + RAG app)

| # | Actor | Action |
|---|--------|--------|
| 1 | Cloud Build | `GET /internal/snapshots/{snapshot_id}` |
| 2 | Cloud Build | Checkout `vertexai-rag` @ `template_version` tag |
| 3 | Cloud Build | `docker build` → push `rag:{app_id}-{sha}` |
| 4 | Cloud Build | `gcloud run deploy rag-{app_id}` + env + secrets |
| 5 | Cloud Run | Cold start RAG app |
| 6 | RAG app | `GET /internal/apps/{app_id}/config` (OIDC) → cache snapshot |
| 7 | Cloud Build | Domain map `{slug}.apps.yourdomain.com` |
| 8 | Cloud Build | `POST /internal/builds/{build_id}/complete` |
| 9 | Portal BE | Update `apps/{app_id}`: `status: live`, `current_revision`, `deploy_success` event |
| 10 | Portal FE | SSE/poll → show live URL |

**Target timing (NFR):** p95 deploy &lt; 10 minutes; poll interval 5–10s.

---

## 6. App state machine

```text
                    POST /api/apps (deploy=true)
         ┌──────────────────────────────────────┐
         ▼                                      │
    ┌──────────┐   build OK    ┌──────┐        │
    │ deploying│──────────────►│ live │        │
    └──────────┘               └──────┘        │
         │ build fail              │           │
         ▼                         │ PATCH only│
    ┌──────────┐                  │ (config   │
    │  failed  │                  │  drift)   │
    └──────────┘                  │           │
         │ retry deploy            │           │
         └──────────────────────────┘           │
                    DELETE / archive           ▼
                                          ┌──────────┐
                                          │ archived │
                                          └──────────┘
```

| Status | Meaning | Allowed transitions |
|--------|---------|---------------------|
| `deploying` | Build in progress | → `live`, `failed` |
| `live` | Revision serving traffic | deploy, rollback, sight, archive |
| `failed` | Last build failed | redeploy, archive |
| `archived` | Teardown / read-only | none (v1) |

---

## 7. Update paths (when CRUD is not enough)

| Change type | API | Deploy? | Runtime effect |
|-------------|-----|---------|----------------|
| Sight on/off | `POST .../sight` | No | Hot reload → middleware |
| Prompt-only tweak (if supported) | `POST .../sight` or reload endpoint | No | Hot reload |
| LLM model, persona_id, RAG knobs | `PATCH` then `POST .../deploy` | **Yes** | New revision + snapshot |
| Persona library edit | `PATCH /api/personas/{id}` | Manual per app | No auto-update (Flow F) |
| Rollback | `POST .../rollback` | No new build | Traffic shift only |

**Config versioning rule:** Running revision always resolves config via **`snapshot_id` baked at deploy**. `PATCH /api/apps` updates the *draft* live record; apps do not see it until redeploy or hot-reload path.

---

## 8. Read operations (detail)

### `GET /api/apps`

**Query params:** `status`, `owner`, `page`, `page_size`, `search`

**Response item shape:**

```json
{
  "app_id": "technician",
  "name": "Technician Assistant",
  "slug": "technician",
  "status": "live",
  "sight_enabled": true,
  "persona_id": "field-technician",
  "current_revision": "rag-technician-00042-abc",
  "url": "https://technician.apps.yourdomain.com",
  "health": "ok",
  "last_deploy_at": "2026-05-15T10:02:00Z"
}
```

### `GET /api/apps/{app_id}`

Includes full config, last 5 `deployment_events`, linked `snapshot_id` for current revision.

---

## 9. Delete / archive

**`DELETE /api/apps/{app_id}`** (v1 = soft archive)

1. Set `status: archived`, `updated_at`.
2. Write `deployment_events` type `archive`.
3. (Phase 5) Set Cloud Run traffic to 0%; delete service; keep Firestore for audit.

**Guards:** Confirm in UI; admin may force archive any app.

---

## 10. API error contract (app CRUD)

| HTTP | Code | When |
|------|------|------|
| 400 | `VALIDATION_ERROR` | Invalid slug, missing persona, bad config ranges |
| 401 | `UNAUTHORIZED` | Missing/invalid JWT |
| 403 | `FORBIDDEN` | Role insufficient |
| 404 | `APP_NOT_FOUND` | Unknown `app_id` |
| 409 | `SLUG_CONFLICT` | `POST` with existing slug |
| 409 | `PERSONA_IN_USE` | `DELETE` persona referenced |
| 422 | `DEPLOY_IN_PROGRESS` | Second deploy while `deploying` |
| 502 | `BUILD_TRIGGER_FAILED` | Cloud Build API error |

**Envelope:**

```json
{
  "success": false,
  "error": {
    "code": "SLUG_CONFLICT",
    "message": "App slug 'technician' already exists"
  }
}
```

---

## 11. Backend module map (suggested)

```text
portal-backend/app/
├── controllers/
│   ├── apps_controller.py      # CRUD + deploy + sight + rollback
│   └── personas_controller.py
├── services/
│   ├── app_service.py          # orchestration
│   ├── persona_service.py
│   ├── deploy_service.py       # Cloud Build trigger + poll
│   ├── snapshot_service.py     # immutable writes
│   └── secret_service.py       # GSM metadata
├── repositories/
│   ├── app_repo.py
│   ├── persona_repo.py
│   ├── snapshot_repo.py
│   └── deployment_event_repo.py
└── schemas/
    ├── app.py                  # CreateAppRequest, AppResponse, ...
    └── persona.py
```

**`AppService.create_app` responsibilities (pseudo):**

```python
def create_app(payload: CreateAppRequest, actor: Developer) -> CreateAppResponse:
    validate_slug_unique(payload.slug)
    validate_persona_exists(payload.persona_id)
    app_id = payload.slug
    snapshot = snapshot_service.create_from_payload(app_id, payload)
    secret_service.ensure_app_secrets(app_id)
    app_repo.create(app_id, payload, snapshot.id, status="deploying", actor=actor)
    deployment_event_repo.record_deploy_start(...)
    build_id = deploy_service.trigger_build(app_id, snapshot.id, payload.template_version)
    return CreateAppResponse(app_id=app_id, status="deploying", build_id=build_id, ...)
```

---

## 12. Frontend routes (suggested)

| Route | CRUD | Notes |
|-------|------|-------|
| `/apps` | Read list | Health pills, filters |
| `/apps/new` | Create | Wizard → `POST /api/apps` |
| `/apps/:id` | Read | Overview |
| `/apps/:id/config` | Update | `PATCH`; “Save” vs “Save & deploy” |
| `/apps/:id/deploy` | Deploy | Revision list, rollback |
| `/apps/:id/observe` | Read | Logs, metrics, traces |
| `/personas` | Read list | |
| `/personas/new` | Create | `POST /api/personas` |

---

## 13. Acceptance criteria (development & QA)

### Create app

- [ ] Given valid persona and unique slug, `POST /api/apps` returns `202` with `build_id`.
- [ ] Firestore has `apps/{id}`, `config_snapshots/{snap}`, `deployment_events` deploy_start.
- [ ] Within 10 minutes (staging), app status becomes `live` and URL resolves.
- [ ] RAG app on boot loads config matching snapshot (not stale draft).
- [ ] Duplicate slug returns `409 SLUG_CONFLICT`.

### Read

- [ ] List returns all non-archived apps with health summary &lt; 2s (NFR).
- [ ] Detail includes current revision and snapshot id.

### Update

- [ ] `PATCH` without deploy updates Firestore only; running app unchanged until redeploy.
- [ ] `POST .../deploy` creates **new** snapshot and triggers build.
- [ ] Sight `false` causes 503 on RAG app within 5s (hot path).

### Persona CRUD

- [ ] Cannot delete persona used by any app (`409`).
- [ ] `PATCH` persona returns `affected_apps` list.

### Archive

- [ ] Archived app excluded from default list; admin can still view audit.

---

## 14. Test data (local / staging)

```json
{
  "persona_id": "field-technician",
  "app": {
    "name": "Staging Technician",
    "slug": "technician-stg",
    "template_version": "v1.4.2",
    "sight_enabled": true
  }
}
```

Use mockup [`admin-operator-mockup.html`](./admin-operator-mockup.html) for UX walkthrough before API exists.

---

## 15. Related sequence diagrams

| Flow | File | Topic |
|------|------|--------|
| A | [flow-a-deploy.mmd](./flows/flow-a-deploy.mmd) | Create + first deploy |
| B | [flow-b-runtime.mmd](./flows/flow-b-runtime.mmd) | End-user query |
| C | [flow-c-sight.mmd](./flows/flow-c-sight.mmd) | Sight hot toggle |
| E | [flow-e-rollback.mmd](./flows/flow-e-rollback.mmd) | Rollback |
| F | [flow-f-persona.mmd](./flows/flow-f-persona.mmd) | Persona update propagation |
| G | [flow-g-admin.mmd](./flows/flow-g-admin.mmd) | Admin operator |

---

## 16. Open decisions (track in PRs)

| # | Question | Recommendation |
|---|----------|----------------|
| 1 | `app_id` vs `slug` separate? | Same value in v1 |
| 2 | Auto-deploy on `POST /api/apps`? | Yes (`deploy: true` default) |
| 3 | Who can create apps? | All developers; admin oversees all |
| 4 | Secret values on create? | Admin pastes once → GSM; never in Firestore |
| 5 | `PATCH` creates snapshot automatically? | Only on explicit deploy or “Save snapshot” checkbox |

---

*Last updated: 2026-05-15 · aligns with HLD §4–5 and implementation plan Phase 1–2.*
