---
id: w1/m8/t003
title: "Public API: plan on the service view + a plan-change verb (row-first), unknown plan ⇒ 400"
worker: worker1
status: done
estimate: 45m
depends_on: [w1/m8/t001]
---

## Objective

Surface Render's `plan` on the Render-compatible surface: every service view reports its plan (from `spec.tier` via the catalog's `renderPlan` spelling), and a plan-change verb accepts Render's `serviceDetails.plan` — row-first for store-managed apps, CR-patch for bare-CR apps — with unknown plans rejected 400 at the API edge.

## Context

- **The public apps feature has no Create verb** (`lego/backend/internal/apps/service.go`: List/Get/Restart/Suspend/Resume + `patch`) — creation is the internal tenant API (already tier-validated) or a bare CR. So this task is *view + update*, not create.
- **Three-adapter parity rule** (`internal/api/CLAUDE.md`): one implementation in the apps service; REST/GraphQL/MCP fragments map it. REST: `serviceDetails.plan` (Render's OpenAPI — `render.go:39` currently passes `ServiceDetails` as a free map; verify the exact enum spelling, e.g. `pro_plus`, and record it in the catalog's `renderPlan`, t001). GraphQL per captured dashboard vocabulary; MCP only if render-mcp-server has it — omit where Render omits.
- **Row-first seam exists**: `apps.IntentStore` (`service.go:40-53`, single method `SetAppSuspended`) — add a sibling `SetAppTier`, implemented by `*store.PGStore`, so the row owns the tier for store-managed apps (the projector then patches the CR, same as suspend). Bare-CR apps go through the existing `patch` helper.
- Mapping: API `plan` (renderPlan spelling) ⇄ catalog ⇄ `spec.tier` (id spelling). CRD `spec.tier` stays an open string — strictness lives only at this edge. A view whose `spec.tier` is empty/unknown omits `plan` (safe-superset rule: omit, don't fake).
- A plan change rolls the Deployment (new resources ⇒ pod restart) — same semantics as Render; say so in the verb's doc comment.

## Steps

1. Add `plan` to `AppView` + the Render view mapping; empty/unknown tier ⇒ field omitted.
2. Add the plan-change verb (starting with `s.Authorize(ctx, core.RelCanOperate)`), row-first via the new `SetAppTier`, CR-patch fallback; validate via the catalog, 400 listing valid plans.
3. Fan out to the three adapter fragments per their Render counterparts; parity considered in each.

## Files

- `lego/backend/internal/apps/` (service.go, models/render shapes, rest/graphql/mcp fragments)
- `lego/backend/internal/store/store.go` (SetAppTier)

## Acceptance criteria

- [ ] `GET /v1/services/{id}` reports `plan` for a tiered App and omits it for an untiered one; plan update lands on the row (store-managed) or `spec.tier` (bare CR).
- [ ] Unknown plan ⇒ 400 (REST) / error (GraphQL) listing valid names; verb refused without `RelCanOperate`.
- [ ] Backend tests green, including surface-parity checks.

## Out of scope

- Public create; a `plans` catalog endpoint; dashboard UI; default/backfill for legacy apps (t004).
