# Atomic Edge Promote (first slice)

Cloudflare-only content release loop: immutable snapshots, channel promote/rollback, same-Worker draft/published perspectives, locale provenance, and Storyblok-class visual edit on demo routes.

**PRD:** `DL-11` (Partial)

## What shipped

| Piece | Where |
| --- | --- |
| D1 schema: `content_releases`, `channel_pointers`, `locale_cascades` | migration `0038_content_releases.sql` |
| Create release → KV payload + D1 metadata | `POST /api/admin/releases` |
| Promote / rollback channel pointers | `POST /api/admin/releases/promote`, `/rollback` |
| Public reads honor channel release when promoted | `GET /api/public/:collection…` |
| Draft perspective (header/cookie + preview token) | `X-EdgeCMS-Perspective: draft` + `previewToken` |
| Locale response metadata | `X-Content-Locale-Resolved`, `X-Content-Locale-Provenance` |
| Locale-exact promote gate | Field `localeExact` + `GET …/releases/:id/locale-readiness`; promote → `LOCALE_EXACT_NOT_READY` on gaps |
| Author UI | Admin → **Releases** |
| Demo routes + visual overlays | `GET /api/demo/pages/:slug?visualEdit=1` |
| Visual-edit host (postMessage → D1 PATCH) | `GET /api/demo/host/:slug` + admin entry editor listener |
| Field commit API | `PATCH /api/admin/entries/:id/visual-edit` |
| Client SDK overlays | `@edgecms/client` → `mountVisualEdit`, `listenToVisualEditCommits`, `listenToVisualEditMessages`, `createVisualEditPatchQueue`, `visualEditAttrs` |
| Visual-edit polish | Bidirectional highlight (admin ↔ iframe), demo field coverage (faq/gallery/seo), conflict overwrite + ACK on host PATCH |

## Local run (wrangler)

```bash
cd edgecms-saas
bun install
bun run --filter edgecms-api db:migrate:local
bun run db:seed:smoke -- --tenant-slug partner   # first-time local admin+tenant
bun run dev:api      # API/worker :8787
bun run dev:admin    # admin :5173  (or `bun run dev` for api+admin+website)
```

Admin: `http://localhost:5173` → tenant → **Releases**.

Demo page (after a `pages` entry exists and a release is promoted):

```bash
curl -i "http://localhost:8787/api/tenants/<tenant>/api/demo/pages/home?locale=en&visualEdit=1"
# or global public mount when tenant middleware resolves:
curl -i -H "x-tenant: <tenant>" "http://localhost:8787/api/demo/pages/home?visualEdit=1"
```

Draft on the same route:

```bash
# mint via POST /api/public/preview-token (or admin live-preview flow)
curl -i \
  -H "x-edgecms-perspective: draft" \
  -H "x-edgecms-preview-token: <token>" \
  "http://localhost:8787/api/public/pages/home?locale=zh-CN"
```

Deploy (staging/production) uses existing wrangler envs; apply migration `0038` before relying on releases:

```bash
cd apps/api
bun run db:migrate:remote   # or env-specific migrate scripts
bun run deploy:staging
```

## Author loop

1. **Releases → Ensure demo content** (idempotent `pages` / `home`) if starting cold.
2. Edit via **Visual edit host** or admin entries (DB is content truth).
3. **Releases → Publish snapshot** (optionally promote to `published`).
4. Channel pointer flip is the promote/rollback — no rebuild.
5. Open `/api/demo/pages/<slug>?visualEdit=1` for overlays; prefer `/api/demo/host/<slug>` so the iframe is **draft + preview token** and PATCH includes CSRF.
6. Persist in-place edits → `PATCH …/api/admin/entries/:id/visual-edit` (D1 truth).

**Partner script (5 minutes):** [partner-demo-5min.md](./partner-demo-5min.md).

```bash
# Host shell (auth required; iframes draft perspective; CSRF on PATCH)
curl -i -b <session> "http://localhost:8787/api/tenants/<tenant>/api/demo/host/home?locale=en"
```

## Environments × channel releases

Linked environments (`link` / `diff` / `push`) are **cross-instance transfer**, not a second promote UX.

| Mode | Behavior |
| --- | --- |
| Release-sourced push | `POST …/environments/:id/push` with `sourceChannel` or `sourceReleaseId` → entries from the immutable KV release (schemas still from local D1) |
| Remote promote | `promoteChannel` on the push (or receiver `options.promoteChannel`) → after D1 apply, `createRelease` + channel pointer flip on the target |
| Live-draft push | Omit source fields → legacy bootstrap sync of live D1 rows (not a promote) |

Audit: composed pushes emit `environment.release_pushed` with join keys `environmentId` × `sourceReleaseId` × `sourceChannel` / `promoteChannel` / `promotedReleaseId`. Remote create/promote still emit `release.created` / `release.promoted`.

Admin → **Environments** defaults to source=`published` + promote=`published`. Do **not** market environments as equal to channel promote.

## Still stubbed / follow-ups

- Live public path without a channel pointer uses the pre-existing KV list/entry cache and now emits the same served-locale / field provenance metadata as the release path (`_localeProvenance`, `X-Content-Locale-Resolved`, `X-Content-Locale-Provenance`). Legacy KV payloads without provenance keep chain headers until the next snapshot miss regenerates them.
- GraphQL public reads (`/api/graphql`, tenant-scoped variants) reuse the same cascade + field provenance contract: entry `_localeProvenance` / `_localeChain` plus the same delivery headers on successful queries.
- Packaged SIEM connectors / Git dual-write / public SLO marketing — out of slice (hookable audit webhook/NDJSON sink + content lifecycle audit events — [operations-security.md](./operations-security.md)).
- Bet ⑦ release-scoped semantic retrieval — **spike only** (not GA): opt-in `ENABLE_RELEASE_SEMANTIC` indexes on promote + admin search; limits in [release-semantic-spike.md](./release-semantic-spike.md).
- Partner synergy glue: `GET …/releases/demo/synergy` + `POST …/releases/demo/abac-deny` bind visual-edit × locale-exact readiness × promote × environment×release push × locale provenance × ABAC denials ([partner-demo-5min.md](./partner-demo-5min.md)).
- Internal latency hooks: `releases.edge_read` (draft/published edge reads) and
  `releases.promote_flip` (create/promote/rollback). Structured Workers Logs
  (`type: "latency"`) carry aggregation `labels` plus sink-only `join` keys
  (`releaseId`, `channel`, `requestId`, `locale`) so ops can correlate draft /
  published / promote samples without exploding series cardinality. Optional
  Analytics Engine `METRICS` binding appends the same join blobs. Isolate
  samples expose p50/p95 for ops — no customer-facing SLO claim. Internal board
  + join-key alerts: [latency-slo-ops.md](./latency-slo-ops.md).

## Snapshot materialization

`createRelease` pages D1 via `findVisibleByCollection` (`perPage` 100, the repository ceiling) until a collection is exhausted. Visibility uses one frozen `now` for the whole snapshot. Payloads over 20 MiB fail with `SNAPSHOT_TOO_LARGE` before any KV write (Cloudflare KV hard-caps ~25 MiB).

## Channel promote / rollback (hardened)

- Channels are independent: `published` and `staging` pointers never share history.
- Create may promote via `promoteToChannel` (`published` | `staging`) or legacy `promoteToPublished`.
- Pointer flips use compare-and-swap on the current `releaseId`. Concurrent losers get `POINTER_CONFLICT` (HTTP 409); clients retry after refresh. Optional `expectedReleaseId` on promote/rollback is an explicit CAS guard.
- Re-promoting the release already on a channel is idempotent (history preserved).
- Promote refuses when the immutable KV payload is missing (`SNAPSHOT_MISSING`).
- Promote refuses when any **locale-exact** field lacks an exact value for a configured cascade target or collection `defaultLocale` (`LOCALE_EXACT_NOT_READY`, HTTP 409). Preview with `GET /api/admin/releases/:releaseId/locale-readiness`. Fallible localizable fields still cascade on delivery; locale-exact fields never do.
- Create → promote failures leave the immutable release in place and return `PROMOTE_FAILED` with the new `releaseId` so the author can retry the pointer flip.
