ADRs
ADR 0052 — Arno Sorgente: shared-DB architecture (supersedes 0049's implementation approach)
  • Date: 2026-06-08
  • Status: Accepted — amended 2026-07-03 (storage location, see Amendment below)
  • Phase / Feature: Sorgente — implementation architecture
  • Builds on: ADR 0049 (Arno Sorgente product intent), ADR 0050 (Foundation evolution sibling)
  • Supersedes (partially): ADR 0049 §1-§7 implementation details. Product intent ("ARNO self-hosts its own UI", maintainer iterates inside the product, edits flow without code-edit-deploy round-trips) stays unchanged. This ADR replaces the per-env DB + publish/promote workflow approach with a shared-DB approach that delivers the same outcome with less moving infrastructure.

Context

ADR 0049 specified Sorgente as a project (prj-arno-sorgente, later relaxed to "any project flagged is_arno_sorgente = true") existing once per environment, with explicit Publish to source and Sync from upstream actions to move state between envs. After building Phases A+B + multi-env Rounds 1-6, the maintainer's actual workflow surfaced three constraints the per-env model misses:

  1. Sorgente edits must be instant across the maintainer's whole working surface — every test env, dev env, every project they have open — not gated behind a publish button. The maintainer iterates on the design system while building features in test branches; waiting for a workflow round-trip breaks flow.
  2. Sorgente is a single conceptual artifact, not a per-env copy. Asking the maintainer "which env's Sorgente am I editing?" makes no sense in their head: they have one design system, surfaced everywhere they work.
  3. Prod has no Sorgente. It's the canonical user-facing release surface; the live editor / DB-backed brand cascade is a development-time tool, not something end users see. Prod ships only a static CSS snapshot from the last release.

The persona: one designer/builder of ARNO (currently the maintainer themselves). They iterate on UI inside test branches with Claude, occasionally land features to dev, eventually ship to prod via the standard release (prod) flow. The design system evolves as a side-effect of feature work — the maintainer hits a constraint (a missing spacing token, a layout primitive that doesn't bend right) and needs to extend the system right now, inside the same surface they're already using.

Decision

§1 — Storage: one schema, one DB

A single Postgres schema sorgente lives in the dev Neon database. All Sorgente brand / variant / composition state is stored there. There is no per-env Sorgente data anywhere else.

dev Neon — schema "sorgente"
  sorgente.brand_seed
  sorgente.brand_binding
  sorgente.brand_font
  sorgente.brand_icon
  sorgente.token_override
  sorgente.wcag_override
  sorgente.variant_override        (Layer 2, schema ready, edit UI lands with ADR 0044)
  sorgente.composition_instance    (Layer 3, schema ready, edit UI lands with ADR 0030 Phase F+)
  sorgente.apply_log               (audit — one row per Apply press)

Every row is keyed by user_id (FK to the user who owns the edit). Solo maintainer today → one user. Multi-maintainer (see §6) just means more user_id values share the same schema.

§2 — Access: test + dev workers read/write live; prod doesn't

Each non-prod worker gets a wrangler secret SORGENTE_DATABASE_URL pointing at the dev Neon connection string. Sorgente endpoints in apps/api/src/sorgente.ts use getDB(env.SORGENTE_DATABASE_URL) for all reads and writes — independent of which env the worker itself is running in.

The prod worker does NOT receive SORGENTE_DATABASE_URL. Sorgente endpoints there fail closed (404). No edit surface, no overlay, no button — the entire Sorgente feature is invisible on arnomake.com.

Prod consumes Sorgente only as a static artifact:

  • release (prod) workflow reads from SORGENTE_DATABASE_URL (= dev Neon) at release time
  • Generates apps/web/src/app/globals.css + packages/foundation/dist/arno-sorgente.css from the resolved sorgente state
  • Commits, builds, deploys
  • End users see the snapshot from the most recent release
        ┌─────────────────────────────────────────────────────┐
        │  DEV NEON, schema "sorgente"  ← single source of    │
        │  brand/variant/composition state                     │
        └────────┬────────────────────┬───────────────────────┘
                 │ live read+write    │ live read+write
                 │ SORGENTE_DB_URL    │ SORGENTE_DB_URL
        ┌────────▼─────────┐   ┌──────▼─────────┐
        │ TEST workers     │   │ DEV workers    │
        │ (per branch,     │   │                │
        │  ephemeral)      │   │                │
        └──────────────────┘   └────────────────┘

        ┌─────────────────────────────────────────────────────┐
        │ release (prod) — manual workflow_dispatch           │
        │ reads sorgente schema → generates globals.css +     │
        │ arno-sorgente.css → commits + builds + deploys       │
        └────────────────────────┬────────────────────────────┘

                         ┌──────────────────┐
                         │ PROD             │
                         │ static CSS bake  │
                         │ no DB access,    │
                         │ no Sorgente UI   │
                         └──────────────────┘

§3 — Edit model: Apply / Reset gate at the inspector

Sorgente edits propagate instantly after the maintainer explicitly commits them. Per-element granularity, via the existing inspector surfaces (LayoutInspector, SpacingInspector, BrandPanel — collectively "the Sorgente inspectors").

Maintainer opens an inspector, picks an element / token

  ├─ Tool fetches current value from sorgente.* via GET /sorgente/state
  │  → fills the fields

  ├─ Maintainer edits a field
  │  → local React draft (only visible in this browser tab)
  │  → DOM preview via :root.style.setProperty (live in this tab)

  ├─ [Reset to last applied] → drop draft → field + DOM revert
  │  to sorgente.* value

  ├─ [Apply] → PUT /sorgente/<table>/<id>
  │  → sorgente.* DB write
  │  → SorgenteOverlay poll on every other tab / env
  │    picks up new state within 2s

  └─ [Apply all pending in this panel] (group commit)
     batch PUT for several drafts at once — used when editing
     a related cluster (brand seed + bindings) where applying
     individually would leave inconsistent intermediate states

The Apply gate is the safety mechanism: edits stay private until intent is explicit. Reset gives one-step undo back to the last committed value. Full multi-step history is out of scope; the sorgente.apply_log audit row exists for forensics and future revert UI.

§4 — Propagation: live polling

The browser surface that needs to react to remote Sorgente changes (other tabs, other envs, future second maintainer) uses a 2s polling loop in apps/web/src/components/sorgente-overlay.tsx. The overlay fetches /sorgente/state, diffs against the currently-injected <style id="arno-sorgente-overlay"> content, re-injects only on change.

The choice of polling over SSE/WebSocket: CF Workers don't hold long-lived SSE connections cleanly (ADR 0049 §11 open question, validated by industry reports), and 2s polling for a solo session is cheap (~30 reads/min against a Neon branch that's already serving the editor). When the session count grows past a couple maintainers, this layer swaps to Liveblocks Awareness (§6) — Liveblocks is already in the stack for workflow/composition, so the upgrade is incremental.

§5 — The v9-synth migration

The existing ARNO chrome lives in apps/web/src/app/globals.css as hand-coded CSS variables (--surface-base, --mint, --violet, --r-*, --text-*, etc — the "v9-synth direction", renamed to "Arno Sorgente" per ADR 0049 §0). This file becomes the first set of Sorgente data:

  1. A one-shot port (tools/port-v9-synth-tokens/run.ts) parses every CSS variable in globals.css and inserts a corresponding sorgente.token_override row.
  2. From that point forward, globals.css is auto-generated at build time from sorgente.token_override. Editing it by hand is forbidden — the maintainer edits via the Sorgente inspectors instead.
  3. The Foundation namespace tokens (color.interactive.primary.default, etc) live in the same sorgente.token_override table — there's no "Foundation table" vs "app table" split. Token id is just a path string; the resolver maps it to the right CSS variable.

§6 — Multi-maintainer evolution

solo (current + maybe one more soon)
└─ shared sorgente schema, last-write-wins per row,
   Apply gate as the only safety net.

2-3 maintainers (next step)
└─ + Liveblocks Awareness layer on the inspector panels:
   live cursors, "Anna is editing token X" badge on fields,
   visible Apply events from peers. Conflicts resolve at the
   UX layer (Anna sees Boris is in the field, waits or
   chats). Same model Figma uses for realtime canvas.

5+ maintainers (eventual)
└─ + per-maintainer drafts: each maintainer has a scratch
   copy of sorgente.* keyed by their user_id, explicit
   "Promote my draft to shared canonical" action. Canonical
   stays one; drafts isolate. Same shape as Figma Branches.
   Apply pipeline reroutes through the promote step.

The schema is already keyed by user_id so the Phase 3 migration is purely backend — no DB rework. Phase 2 is purely frontend (Liveblocks integration on three components). Neither blocks current solo work.

§7 — Why not the per-env approach

ADR 0049's original implementation gave each env its own Sorgente project and bridged them with publish/promote workflows. The 6-round multi-env build proved the approach works but exposed three sustained costs:

  • Coordination tax per edit: every meaningful change required at least one button press (Publish) + workflow run (~2-3 min). For a maintainer iterating in 30-second feedback loops, this is the wrong granularity.
  • Per-env state divergence: maintainer's mental model is "one Sorgente"; the implementation forced them to keep track of which env's copy was current. Sync workflows existed but added more buttons.
  • Infrastructure surface: 3 dedicated workflows (publish, promote, seed) + audit log table + workflow_dispatch token + Pull/Publish UI + per-env CSS export + audit columns — all infrastructure built to move state around. Shared-DB approach removes all of it: state doesn't need to move because it's already in one place.

The product intent of ADR 0049 — maintainer self-hosts ARNO's UI, edits land in production through ARNO's own primitives — survives. Only the storage layout changes.

§8 — Removed in this ADR

The following stop existing when this ADR lands:

  • POST /api/v1/projects/arno-sorgente/publish and /sync-from-upstream endpoints
  • apps/web/src/components/publish-sorgente-button.tsx and its Pull/Publish UI
  • .github/workflows/arno-sorgente-promote.yml, arno-sorgente-publish.yml, seed-arno-sorgente-from-prod.yml, seed-gh-dispatch-token.yml, apply-arno-sorgente-migrations.yml, apply-foundation-migrations-dev.yml, debug-dev-sorgente.yml
  • project.is_arno_sorgente column + project_arno_sorgente_singleton partial unique index
  • arno_sorgente_publish_log table (replaced by sorgente.apply_log)
  • isArnoSorgente field on the /api/v1/me/projects response
  • Per-env Sorgente CSS export steps in deploy-test.yml and ci.yml
  • GH_DISPATCH_TOKEN wrangler secrets on test + dev (no more workflow_dispatch from API)

Open questions

  • Cross-env latency: test workers in a different CF region than dev Neon may add 50-150ms to every Sorgente read. Acceptable today (solo, small dataset, ~5 reads per session-minute). Re-evaluate if a second maintainer in a different region surfaces lag.
  • Backup: sorgente schema in dev Neon needs the same backup retention as the rest of dev. Neon's per-branch PITR covers this automatically; nothing to add.
  • Renamed schema namespace conflicts: if a maintainer ever needs to use dev_arno.sorgente.* from a non-Sorgente endpoint by mistake, drizzle's schema-aware mapping should prevent it. Verify via the typecheck pass after migration.

Sequence

The migration lands as one coherent PR — partial state would mean two models alive at once, which is worse than the destructive cut.

  1. Create sorgente schema + 9 tables (migration + journal entry).
  2. Apply migration to dev Neon (one-shot workflow apply-sorgente-schema.yml).
  3. Set SORGENTE_DATABASE_URL secret on test + dev workers.
  4. Author apps/api/src/sorgente.ts — GET state, PUT per-table Apply endpoints.
  5. Port apps/web/src/app/globals.css into sorgente.token_override (one-shot script).
  6. Rewrite SorgenteOverlay to read from /sorgente/state.
  7. Add Apply/Reset to LayoutInspector + SpacingInspector + BrandPanel.
  8. /app/sorgente becomes a standalone strip UI (no ?project= query).
  9. release (prod) exports from shared DB; build step auto-generates globals.css.
  10. Delete everything in §8.
  11. Update HANDOFF.md.
  12. Commit, push, merge when ready.

Amendment — 2026-07-03: storage moved to the rotating test DB

What changed. §1's "dev Neon" never materialized in practice: the DEV_DATABASE_URL secret that seeded SORGENTE_DATABASE_URL pointed at the free-tier TEST Neon project, so the sorgente.* schema has lived in the test DB since at least 2026-06-18. When that project was retired and deleted after the first automated rotation (test-db-rotate.yml), every Sorgente endpoint broke — surfacing the mismatch.

Decision. Accept reality and harden it: Sorgente state lives in the ROTATING test DB — the Neon project named arno-test-active. Consequences:

  • deploy-test.yml seeds SORGENTE_DATABASE_URL from the SAME resolved URL as DATABASE_URL (Neon API by project name, DATABASE_URL_TEST fallback).
  • test-db-rotate.yml reseeds both secrets together on rotation; the sorgente.* schema travels inside the nightly dump, so Sorgente data survives rotation (verified live 2026-07-03).
  • release.yml resolves the Sorgente export source the same way — DEV_DATABASE_URL is no longer read anywhere on the test/release path.
  • Backup story replaces §"Open questions / Backup": Neon PITR no longer applies across rotations; the nightly test-db-dump artifact (30 days) is the retention mechanism.

Unchanged. Product intent, §2's prod-has-no-Sorgente rule, the schema shape, and the single-conceptual-artifact model. arno-api-dev keeps its own DEV_DATABASE_URL wiring (out of scope here).

Open. Historical Sorgente rows on the Arno Neon project's production branch (pre-June work) are not read by any worker; whether to import them into arno-test-active is an open maintainer decision.

Amendment — 2026-07-14: sign-in access split from Sorgente write access

What changed. Until this point, one wrangler var — AUTH_ALLOWED_GITHUB_LOGINS — gated BOTH questions at once: "may this GitHub login sign in to the dev/test worker" and "may this login mutate sorgente.*" (requireMaintainer() called the same isLoginAllowed() the OAuth callback used). Granting a collaborator dev/test access for the first time (boris.ivanov.mail@gmail.com, GitHub racemus) exposed the coupling: the only way to let them sign in was to also hand them write access to the shared design system §6 describes as the eventual multi-maintainer surface — a bigger grant than "browse dev" calls for.

Decision. Split the gate into two independently-configured allowlists, both in apps/api/src/auth-allowlist.ts:

  • isLoginAllowed(env, login) — unchanged. Gates OAuth sign-in via AUTH_ALLOWED_GITHUB_LOGINS. Unset → allow-all (prod default).
  • isSorgenteMaintainer(env, login) — new. Gates requireMaintainer() via SORGENTE_MAINTAINER_LOGINS, a subset of the sign-in list. When unset, falls back to isLoginAllowed (non-breaking on any worker that hasn't declared it, prod included — prod has no SORGENTE_DATABASE_URL anyway per §2, so the gate is unreachable there regardless).
apps/api/wrangler.dev.toml + wrangler.test.toml:
  AUTH_ALLOWED_GITHUB_LOGINS = "vadimpianov,racemus"   # sign-in: both
  SORGENTE_MAINTAINER_LOGINS = "vadimpianov"           # Sorgente writes: one

racemus can now sign in and browse dev/test end-to-end; any Sorgente endpoint returns 403 forbidden for that login regardless of sign-in status. This is the access level the operational rule (Rules.md §"Два бэкенда") calls level B — read the section before granting the next collaborator access, so the default ask is "sign-in only" and a maintainer grant (level A, adding to both lists) is a deliberate second step, not the accidental default.

Relation to §6. This is NOT the multi-maintainer evolution described in §6 — no Liveblocks Awareness, no per-maintainer drafts, still last-write-wins among whoever IS in SORGENTE_MAINTAINER_LOGINS. It is the prerequisite plumbing: §6's phases assume every signed-in collaborator is already a maintainer; this amendment makes "signed-in but not a maintainer" a first-class, supported state, which is what a reviewer/tester role actually needs.

Tests. apps/api/src/auth-allowlist.test.ts — the load-bearing case is isLoginAllowed(racemus) === true while isSorgenteMaintainer(racemus) === false, plus fallback-to-isLoginAllowed when SORGENTE_MAINTAINER_LOGINS is unset.

Shipped. PR #81 (opens in a new tab), merged to main 2026-07-14 → deployed to arno-api-dev via ci.yml; test stands got it via feat/** push the same day.