ADRs
ADR 0027 — Composition zones unification + deploy mutex
  • Date: 2026-05-27
  • Status: Accepted
  • Affects:
    • packages/db/src/schema.ts — add screen_composition.zones JSONB nullable
    • tools/migrate/drizzle/ — new migration adding zones column
    • packages/shared/src/payloads.ts — export ScreenZonesPayload mirroring @arno/shared
    • apps/api/src/index.ts — PUT /compositions accept { zones }, GET return both shapes
    • apps/api/src/deploy.ts — read full zones with legacy fallback, KV push_lock mutex
    • apps/web/src/lib/api-client.tssaveComposition send zones
    • apps/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.instances JSONB stores a flat CompositionInstancePayload[] (legacy body children only)
  • PUT /api/v1/compositions/:projectId/:screenId accepts { instances: [] } only
  • Frontend persistComposition sends screen.zones.body.children["children"] only (persistence-loader.tsx:126-128 (opens in a new tab))
  • Sidebars/header/footer live exclusively in localStorage via saveScreenLocal

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 zones provided: store zones + derive instances = zones.body.children.children for backwards-compat reads
  • If only instances provided: store instances + leave zones = 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.02.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