---
id: w2/m5/t001
title: Deploys table in internal/store + recording from App revision/phase
worker: worker2
status: done
estimate: 30m
depends_on: [w1/m2/t005]
---

## Objective

Record every rollout as a deploy row: a `deploys` table (id `dep-…`, app, image/commit, status, timestamps) plus the recording logic that stamps transitions from what the operator reports on the App (revision bumps, phase, health gating) — so history is truthful, not synthesized on read.

## Context

- The control-plane store (`lego/backend/internal/store`, w1/m2) already runs migrations and a projector with status write-back — the natural place to observe revision/phase transitions and stamp deploy rows.
- Render deploy statuses to map onto docs/ADR004-deployment.md's flow: `build_in_progress` (only meaningful once w1/m5 builds exist), `update_in_progress`, `live`, `deactivated`/`canceled` (defer), `*_failed`.
- Requires `BEX_CP_DB_URI`; without the store the deploys surface returns 503 (omitted, not faked — the env-vars precedent).

## Steps

1. Migration: `deploys` table (id, app_id, trigger, image/commit, status, created/started/finished timestamps).
2. Recording: on projected spec change or observed revision bump, open a deploy row; close it `live`/`failed` from status write-back (health gating).
3. Read verbs in the apps (or a small deploys) feature: `ListDeploys(appID)`, `GetDeploy(appID, deployID)`.

## Files

- `lego/backend/internal/store/migrations/`
- `lego/backend/internal/store/reconciler.go` (write-back hook)
- `lego/backend/internal/apps/service.go` (or a new deploys feature package)

## Acceptance criteria

- [ ] A rollout produces exactly one deploy row that transitions to `live` when the App is healthy, `failed` when it never gates healthy.
- [ ] `ListDeploys` returns newest-first; `GetDeploy` by `dep-…` id; unknown → `ErrNotFound`.

## Out of scope

- REST/GraphQL/MCP fragments (t002/t004); build-phase statuses (w1/m5); backfill of pre-existing revisions.
