- 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:
- 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.
- 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.
- 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 fromSORGENTE_DATABASE_URL(= dev Neon) at release time- Generates
apps/web/src/app/globals.css+packages/foundation/dist/arno-sorgente.cssfrom 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 statesThe 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:
- A one-shot port (
tools/port-v9-synth-tokens/run.ts) parses every CSS variable in globals.css and inserts a correspondingsorgente.token_overriderow. - From that point forward,
globals.cssis auto-generated at build time fromsorgente.token_override. Editing it by hand is forbidden — the maintainer edits via the Sorgente inspectors instead. - The Foundation namespace tokens (
color.interactive.primary.default, etc) live in the samesorgente.token_overridetable — 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/publishand/sync-from-upstreamendpointsapps/web/src/components/publish-sorgente-button.tsxand 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.ymlproject.is_arno_sorgentecolumn +project_arno_sorgente_singletonpartial unique indexarno_sorgente_publish_logtable (replaced bysorgente.apply_log)isArnoSorgentefield on the/api/v1/me/projectsresponse- Per-env Sorgente CSS export steps in
deploy-test.ymlandci.yml GH_DISPATCH_TOKENwrangler 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:
sorgenteschema 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.
- Create
sorgenteschema + 9 tables (migration + journal entry). - Apply migration to dev Neon (one-shot workflow
apply-sorgente-schema.yml). - Set
SORGENTE_DATABASE_URLsecret on test + dev workers. - Author
apps/api/src/sorgente.ts— GET state, PUT per-table Apply endpoints. - Port
apps/web/src/app/globals.cssintosorgente.token_override(one-shot script). - Rewrite
SorgenteOverlayto read from/sorgente/state. - Add Apply/Reset to LayoutInspector + SpacingInspector + BrandPanel.
/app/sorgentebecomes a standalone strip UI (no?project=query).release (prod)exports from shared DB; build step auto-generatesglobals.css.- Delete everything in §8.
- Update HANDOFF.md.
- 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.ymlseedsSORGENTE_DATABASE_URLfrom the SAME resolved URL asDATABASE_URL(Neon API by project name,DATABASE_URL_TESTfallback).test-db-rotate.ymlreseeds both secrets together on rotation; thesorgente.*schema travels inside the nightly dump, so Sorgente data survives rotation (verified live 2026-07-03).release.ymlresolves the Sorgente export source the same way —DEV_DATABASE_URLis 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-dumpartifact (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 viaAUTH_ALLOWED_GITHUB_LOGINS. Unset → allow-all (prod default).isSorgenteMaintainer(env, login)— new. GatesrequireMaintainer()viaSORGENTE_MAINTAINER_LOGINS, a subset of the sign-in list. When unset, falls back toisLoginAllowed(non-breaking on any worker that hasn't declared it, prod included — prod has noSORGENTE_DATABASE_URLanyway 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: oneracemus 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.