- Date: 2026-05-27
- Status: Accepted
- Affects:
packages/db/src/schema.ts— addscreen_composition.zonesJSONB nullabletools/migrate/drizzle/— new migration addingzonescolumnpackages/shared/src/payloads.ts— exportScreenZonesPayloadmirroring@arno/sharedapps/api/src/index.ts— PUT /compositions accept{ zones }, GET return both shapesapps/api/src/deploy.ts— read full zones with legacy fallback, KVpush_lockmutexapps/web/src/lib/api-client.ts—saveCompositionsend zonesapps/web/src/components/persistence-loader.tsx— persist full zones (drop body-only contract)
- References: ADR 0020 (Layout Grid), ADR 0023 (Deploy pipeline), master spec §I.3.3 (push mutex), §0.4 (data retention)
Context
Discovery 2026-05-27 surfaced two correctness bugs around composition persistence and deploy:
Bug 1 — sidebars/header/footer never persist to backend.
Phase 1-3 introduced the 5-zone layout model (sidebarLeft / header / body / footer / sidebarRight), but the persistence contract was never updated:
screen_composition.instancesJSONB stores a flatCompositionInstancePayload[](legacy body children only)- PUT
/api/v1/compositions/:projectId/:screenIdaccepts{ instances: [] }only - Frontend
persistCompositionsendsscreen.zones.body.children["children"]only (persistence-loader.tsx:126-128 (opens in a new tab)) - Sidebars/header/footer live exclusively in
localStorageviasaveScreenLocal
Bug 2 — deploy ships incomplete artifact.
deploy.ts:153 reads s.instances from Postgres when building pages/{screenId}.layout.json. Since sidebars/header/footer never reach Postgres, published JSON in the connected repo never contains them. Clearing localStorage = silent data loss (no recovery path, no cross-device sync).
Bug 3 — deploy has no concurrency protection.
deploy.ts reads HEAD → builds tree → updateRef without a mutex. Master spec §I.3.3 mandates push_lock:{project_id}:{branch} KV with NX EX 60 to prevent TOCTOU between concurrent deploys. Currently two simultaneous deploys race; the loser gets an unhandled 422 non-fast-forward from GitHub.
These three are bundled because they all touch the same deploy correctness boundary.
Decision
1. Schema — add zones JSONB column (expand-contract)
export const screenComposition = pgTable("screen_composition", {
projectId: text("project_id").notNull().references(...),
screenId: text("screen_id").notNull(),
// Legacy — kept during expand phase. Drop after dual-write soak (Phase 5+).
instances: jsonb("instances").$type<CompositionInstancePayload[]>().notNull().default([]),
// New canonical shape — full 5-zone layout per ADR 0020 §D6.
zones: jsonb("zones").$type<ScreenZonesPayload | null>().default(null),
updatedAt: timestamp("updated_at", ...).notNull().defaultNow(),
}, ...);Nullable zones lets us deploy schema before frontend rolls out, lets old API clients keep working through the soak period.
2. API contract — dual-shape
PUT /api/v1/compositions/:projectId/:screenId:
// Accept either shape. New clients send zones (preferred), legacy clients send instances.
body: { zones?: ScreenZonesPayload; instances?: CompositionInstancePayload[] }Write logic:
- If
zonesprovided: storezones+ deriveinstances = zones.body.children.childrenfor backwards-compat reads - If only
instancesprovided: storeinstances+ leavezones = null(legacy client write)
GET returns both fields. New clients use zones if non-null else migrate instances client-side (existing migrateScreen path).
3. Deploy — read zones with fallback + JSON v2
deploy.ts per-screen serializer:
const screenZones = s.zones ?? migrateScreenServerSide(s.instances);
const content = JSON.stringify({
v: "2.0",
screenId: s.screenId,
projectId,
metadata: { deployedAt, author: c.var.userId },
zones: screenZones,
}, null, 2);Bump JSON version 1.0 → 2.0. Consumers (when defined, see open question) read v and pick parser.
4. KV push_lock mutex
const lockKey = `push_lock:${projectId}:${branch}`;
const acquired = await c.env.SYNC.put(lockKey, instanceId, {
expirationTtl: 60,
// Workers KV has no native NX — read-then-set is racy; accept as MVP risk
// since concurrent deploys per single user are vanishingly rare. Real NX = R2/DO post-MVP.
});
try {
// ...existing deploy flow
} finally {
await c.env.SYNC.delete(lockKey);
}Honest scope limit: Cloudflare KV doesn't support atomic SETNX. True mutex requires Durable Object or R2 conditional write. For MVP single-user dogfood, a read-then-put best-effort lock is sufficient — concurrent deploys per project per branch are theoretical. Documented as known gap; revisit if real concurrent deploy scenarios emerge.
5. Frontend save
persistComposition in persistence-loader.tsx:
const persistComposition = debounce((screenId: string) => {
const screen = compositionStore.getScreen(screenId);
void saveComposition(projectId, screen.screenId, screen.zones);
saveScreenLocal(projectId, screen); // keep localStorage as optimistic cache
}, SAVE_DEBOUNCE_MS);api-client.saveComposition signature: (projectId, screenId, zones: ScreenZonesPayload).
Hydrate in persistence-loader: prefer c.zones from API response, fallback to migrateScreen({ screenId, instances: c.instances }) for rows pre-migration.
Soak period — when to drop instances
Keep instances column populated by dual-write for 30 days after deploy. After:
- Verify no API requests with
{ instances }only via webhook/log probe - Drop column in a follow-up migration
- Remove derived-write in PUT handler
If a third-party CLI or older browser tab is found writing instances during soak, extend by 30d and surface deprecation warning.
Open question — JSON deploy consumer
pages/{id}.layout.json is written but not consumed: the render-adapter (packages/render-adapter) gets live state via postMessage from the ARNO parent, not from git artifacts. The published JSON is currently a deploy snapshot without a reader.
This ADR does not resolve who reads it. A follow-up ADR-0028 must decide:
- (a) Static site generator consuming
pages/*.json(e.g. Next.js fetch at build time) - (b) Runtime fetch from CDN (CF Pages serving the connected repo)
- (c) GitHub Action that compiles JSON → TSX (rejected by ADR 0023 spirit — no codegen)
- (d) Versioned snapshot only, no runtime consumer (deploy = audit trail)
Until ADR-0028 lands, JSON serialization is correctness-only (data must not be lost on deploy), not feature-driving.
Consequences
Positive:
- Deploy stops silently losing sidebars/header/footer
- Cross-device sync works (zones in Postgres, no localStorage dependency)
- localStorage becomes optimistic cache, not source of truth
- Future Phase 4 (versioning, conflict detection) builds on canonical shape
- Master spec §I.3.3 push mutex requirement landed (partial — see scope limit)
Negative / debt:
- Soak window has duplicated state (instances + zones) — 30 days inefficiency
- KV mutex is best-effort, not true NX — gap documented
- JSON consumer remains undefined — ADR-0028 follow-up required
Migration path:
- Schema migration runs before code rollout (expand)
- Code rollout: old clients keep working (legacy contract still accepted)
- Soak: dual-write
- Drop column: contract phase 2, separate PR