ADRs
ADR 0049 — Arno Sorgente: ARNO self-hosts its own UI (test project → main)
  • Date: 2026-06-07
  • Status: Proposed (design spec, not yet implemented)
  • Phase / Feature: Self-hosting / dogfooding
  • Builds on: ADR 0036 (Foundation v1), ADR 0038 (brand persistence), ADR 0039 (Foundation upgrade path), ADR 0045 (composite token refs), ADR 0048 (Colors raison d'être)

Name — Arno Sorgente

The self-hosting project is called Arno Sorgente. Sorgente is Italian for "source / spring / wellhead." Sorgente del Falterona is the real mountain in Tuscany where the river Arno actually begins — its physical source. The product name carries the same chord: this project is the wellhead from which the ARNO UI flows. The river is the live product; the Sorgente is where every drop starts.

Project id: prj-arno-sorgente. Route: /app/sorgente. The name is intended to surface publicly (commit messages, marketing copy, ADRs) — unique, dignified, easy to remember; ties directly to the Renaissance / Arno-river chord ARNO already lives on.

Context

I (the maintainer) am building ARNO and I am also the first designer using it. Right now my workflow looks like this:

  1. I want to change the look of a button in ARNO itself.
  2. I either edit apps/web/src/components/... by hand, or I let Claude edit, or I sketch values in the LayoutInspector dev-tool and paste CSS back into source.
  3. CI deploys, I see the change live.

That round-trip is wrong on two counts:

  • It bypasses the very product I'm building. ARNO promises designers "edit tokens / brand / components and the cascade reaches every consumer." I should be the loudest consumer.
  • It's slow. Code edit → commit → CI → deploy = 3-5 minutes for a 1px shift. The product is supposed to make that round-trip seconds.

This ADR specifies how ARNO becomes its own customer:

  • A real ARNO project owns the production look of ARNO.
  • Edits made inside that project (or any test project linked to it) flow automatically into the deployed main app.
  • The publish path is one-click for me, invisible to users.

Decision

§1 — Two layers: source (static) + overlay (runtime)

Every consumer of ARNO (including ARNO itself) reads its brand state from two CSS variable layers that stack:

  1. Static layer — baked into the bundle. packages/foundation/dist/tokens.css plus the new dist/arno-sorgente.css exported on each release. This layer ships with the app, works under SSR, works offline, and is what every user sees by default.
  2. Runtime overlay layer — fetched on mount. Only mounted for the maintainer in editor mode (gated by JWT + URL flag). Reads the current state of the linked test project, injects override rules into a <style id="arno-self-overlay"> tag at the end of <head>. This is how a 1px edit appears in my own preview within ~250ms — no CI.

The two layers don't replace each other. Overlay sits on top of static via !important + specificity-boost (same trick the existing LayoutInspector uses, see dev-tools/layout-inspector/overrides-store.ts#bumpSpecificity).

End user experience. A normal user signed into ARNO sees only the static layer. They never trigger the overlay fetch. Their app is identical to what they'd see if self-hosting didn't exist.

Maintainer experience. I open the editor surface for the Arno Sorgente project (or a test project linked to it). Overlay activates. I edit color.interactive.primary.default → in 250ms the entire ARNO chrome re-skins to my new value. I see it on real ARNO components (not a preview panel), because I AM looking at the real ARNO.

§2 — The Arno Sorgente project

One canonical ARNO-owned project, identified by:

  • project.id = 'prj-arno-sorgente' (reserved, conventional)
  • project.is_arno_sorgente = true (new boolean column, defaults false)

Only the maintainer can create / modify this project (server-side allowlist on the ownerId). At most one row with is_arno_sorgente = true per environment.

This project stores everything that defines the visual identity of ARNO:

ConceptStorageMechanism
Brand seeds (primary / secondary / tertiary)brand_seedADR 0038
Semantic ↔ ramp bindingsbrand_bindingADR 0038
Custom fontsbrand_fontADR 0038
Brand iconsbrand_iconADR 0041
Per-token overrides (padding, margin, font-size, color, etc)token_overrideADR 0045
WCAG override decisionswcag_overrideADR 0037

No new tables. The whole spec runs on what we already have.

§3 — Linked test projects (the "edit anywhere → reach main" requirement)

A normal designer would publish-when-ready: tweak in their test project, press a button, wait for source to update. That works for a team. For me, it's still too slow — I want every keystroke in any test project to behave as if it were a keystroke in the Arno Sorgente.

Mechanism: a test project can opt in to link mode.

  • project.linked_to_arno_sorgente: boolean (new column, defaults false).
  • Maintainer-only flag (server gate).
  • When true, every mutation to brand_seed / brand_binding / brand_font / brand_icon / token_override on this project is mirrored to prj-arno-sorgente in the same transaction.

The mirror is one-way: linked-project → Arno Sorgente. Edits to prj-arno-sorgente directly do NOT propagate back to linked projects (otherwise the loop closes and nothing converges).

When a linked project is deleted or its linked_to_arno_sorgente flag is cleared, the mirror stops. Already-mirrored values stay in prj-arno-sorgente — they have to be reverted explicitly.

Why linked instead of just editing Arno Sorgente directly? Because a test project is also where I try things that aren't ready yet. Linking gives me the "experiment in a sandbox but it also propagates" affordance without losing the ability to keep a clean canonical project. If I want to test something without upstreaming, I just don't toggle the link.

§4 — Build pipeline (static layer)

New script: tools/export-arno-sorgente/run.mjs.

Steps:

  1. Read prj-arno-sorgente overrides through the existing API client (server- side, with a service token).
  2. Apply them against the Foundation v1 baseline tokens to compute a resolved set: same shape as packages/foundation/dist/tokens.css but with Arno Sorgente values pinned.
  3. Emit:
    • packages/foundation/dist/arno-sorgente.css — CSS-var overrides
    • packages/foundation/dist/arno-sorgente.json — machine-readable snapshot (used for diffing / debugging)
    • packages/foundation/dist/arno-sorgente.meta.json&#123; exportedAt, sourceProjectId, sourceCommitSha &#125; for audit
  4. Idempotent: if the resolved set hasn't changed since the last export, exit zero with no changes.

Wired into .github/workflows/release.yml before the prod backend deploy:

- name: Export arno-sorgente
  env:
    DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }}
  run: pnpm --filter @arno/migrate run export-arno-sorgente
- name: Commit arno-sorgente snapshot
  run: |
    git add packages/foundation/dist/arno-sorgente.*
    if git diff --staged --quiet; then
      echo "no arno-sorgente drift"
    else
      git commit -m "chore(arno-sorgente): snapshot $(date -u +%Y-%m-%dT%H:%M)"
      git push
    fi

apps/web/src/app/layout.tsx imports arno-sorgente.css after tokens.css so it stacks on top of Foundation defaults. SSR-safe — pure CSS file.

§5 — Runtime overlay (live layer)

New mount-only helper: apps/web/src/dev-tools/arno-sorgente-live/ArnoSorgenteLive.tsx.

Activation gate (all three must hold):

  • useAuth() returns the maintainer's user id
  • URL has ?arno-live=1 or session storage flag arno-live-on=1
  • Build mode is not prod-strict (env flag, default permissive on test)

Behaviour when active:

  • Mount → fetch /api/v1/projects/prj-arno-sorgente/full-state (new endpoint: brand_seed + brand_binding + brand_font + brand_icon + token_override bundled).
  • Inject as <style id="arno-self-overlay"> after the Foundation cascade.
  • Subscribe to a server-sent event (/api/v1/projects/prj-arno-sorgente/events) that fires on any mutation to the project's tables. Each event triggers a re-fetch + re-inject. Latency ≈ 250-500ms typical, capped by SSE delivery time. (WebSocket if SSE proves too laggy — same shape on the client.)
  • Unmount → remove the style tag, no leak.

Inactive: no fetch, no tag, identical to a user without the overlay feature.

§6 — Publish flow ("freeze the live layer into the static layer")

A Publish to source button in the maintainer's Library for prj-arno-sorgente:

  • Server endpoint POST /api/v1/projects/prj-arno-sorgente/publish (auth- gated to the maintainer only).
  • The endpoint:
    1. Snapshots the current Arno Sorgente state.
    2. Triggers a GitHub Actions workflow_dispatch on .github/workflows/arno-sorgente-publish.yml.
    3. That workflow runs the same export script as §4 but produces a real git commit + push to main. Release pipeline picks it up automatically.
  • Audit row in a new arno_sorgente_publish_log table — who, when, source snapshot hash, deploy commit sha, success/failure.

If link-mode (§3) is on for some test project, this button is rarely needed — every keystroke is already mirrored. The button still exists as the stable way to freeze a known-good state into the deploy.

§7 — Cadence (manual publish only)

Two clocks, but the second one is maintainer-pressed, not scheduled:

  • Live: mirror writes to Arno Sorgente within the request lifecycle. Overlay sees them via SSE. ~250-500ms end-to-end for me.
  • Static: nothing runs unless I press Publish. Until then, prod users see whatever was published last. Mirror keeps accumulating in prj-arno-sorgente in the background, and I see it live, but it does not reach prod by itself.

Why no cron: surprise-deploys on a scheduled tick are a recipe for shipping a half-finished tweak that a designer assumed was private to their own preview. Manual Publish is the explicit "I've checked this, ship it" gate. Period.

§8 — Scope: everything is in Arno Sorgente, including structure

The maintainer's hard constraint: full control over every ARNO surface from one place. That means tokens AND component composition AND page layout AND routing — all editable inside the linked project, all publishable to source by the same button. No "this lives in code, that lives in DB" split for the maintainer.

This pushes the system past Foundation-style "token overrides" into three publishable layers, each with its own mirror + export shape.

Layer 1 — Tokens (values). Already covered by §1-§7. Mirror writes land in brand_seed/brand_binding/brand_font/brand_icon/ token_override/wcag_override. Export emits arno-sorgente.css. SSR-safe because it's just CSS variables.

Layer 2 — Component composition (variant overrides). A linked project's edits to component variant props (Button.size, Card.elevation, the whole metas surface from ADR 0044) are mirrored into Arno Sorgente. Export emits a TypeScript module packages/foundation/dist/arno-sorgente-variants.ts that the variant resolver reads at module load. Same CSS-var-style mechanism: variant values are looked up by selector at runtime, no JSX gets regenerated. SSR-safe because the resolver runs both server and client side from the same module.

Layer 3 — Structural composition (JSX itself). A linked project's captured pages — its composition trees — are mirrored into Arno Sorgente. This is the heavy layer. Each ARNO page (/app/library, /app/workflow, the navbar, the sidebar, even the Library tab strip) is described as a composition_instance row + captured_page row in the DB (the machinery already exists today for user captured pages, ADR 0030). For the maintainer's linked project this same machinery captures ARNO's own pages.

Publish on Layer 3 runs a codegen step: the export script reads the Arno Sorgente composition trees and writes real .tsx files under apps/web/src/app/... and apps/web/src/components/..., commits the diff, lets the release pipeline rebuild. The hard rule: the codegen is deterministic — same composition tree always produces the same .tsx. No hand edits to generated files survive a publish (a comment header in each generated file says so).

What stays code-side (NOT in DB, NOT mirrored):

  • The Arno engine itself: the renderer that resolves composition_instance into React, the API server, the database drivers, the auth flow, the migrations. The substrate that lets self-hosting work at all.
  • Non-visual logic: data fetching, state machines, side effects, route guards. If it's a hook or a server handler, it's code.
  • The dev-tools (LayoutInspector, SpacingMode, ArnoSorgenteLive itself). Building tools that edit themselves is a rabbit hole; v1 keeps the meta-layer out of scope.

The line: if it renders, it's mirrorable; if it computes, it's code.

§8a — Storage (no new core tables, two new generators)

DB (project-scoped, mutable):

  • Already-existing: brand_seed, brand_binding, brand_font, brand_icon, token_override, wcag_override (ADRs 0037/0038/0041/ 0045) — Layer 1.
  • Already-existing: component_meta_override or whichever table the variant editor lands in (ADR 0044 will introduce it; if not yet, Phase B-bis adds it) — Layer 2.
  • Already-existing: captured_page, composition_instance (ADR 0030) — Layer 3.
  • New columns: project.is_arno_sorgente, project.linked_to_arno_sorgente.
  • New audit table: arno_sorgente_publish_log.

Export pipeline emits:

  • packages/foundation/dist/arno-sorgente.css — Layer 1.
  • packages/foundation/dist/arno-sorgente-variants.ts — Layer 2.
  • apps/web/src/app/{path}/page.tsx, apps/web/src/components/{name}.tsx — Layer 3, regenerated from composition trees. These files have a banner comment marking them generated; CI fails the PR if a human edits them by hand.

Bundle (apps/web):

  • apps/web/src/app/layout.tsx imports Foundation tokens + arno-sorgente.css (Layer 1).
  • The variant resolver imports arno-sorgente-variants.ts (Layer 2).
  • All other components are either Layer 3 generated or substrate code.
  • apps/web/src/dev-tools/arno-sorgente-live/ArnoSorgenteLive.tsx overlay — fetches all three layers' live state on mount, injects a <style> for Layer 1, swaps the variant resolver's module for the linked project's values (Layer 2), and re-mounts the relevant page tree from the linked composition (Layer 3). Latency for Layer 3 swap is higher than Layer 1 because the React subtree remounts — expect ~500-1000ms on a full-page composition change.

§9 — Anti-patterns explicit

  • Do not runtime-fetch arno-sorgente for end users. They are NOT the consumer of the live layer; the static layer is enough. Live fetch = every page load hits the DB, plus a flash-of-baseline before the overlay loads = bad UX.
  • Do not allow two is_arno_sorgente = true rows. Server constraint unique-where-true; if migration needs to move it, it's an explicit one-shot script.
  • Do not mirror back from Arno Sorgente to linked test projects. Loop never closes, and a "test project" stops being a sandbox.
  • Do not allow non-maintainer users to set linked_to_arno_sorgente = true. Server-side gated, no UI exposure to other users.
  • Do not add a scheduled snapshot cron for publish. Every deploy must be preceded by an explicit Publish press. (Snapshots for audit / rollback are a separate machinery and run on schedule — see §11.5.)

§11.5 — Versioning, snapshots, rollback, cross-environment

Six dimensions the original draft underspecified. Industry-standard shape for an editorial system that ships generated code:

Hourly snapshots (audit timeline, not deploy). Every hour a job reads all Sorgente-owned tables for prj-arno-sorgente and writes a single row into arno_sorgente_snapshot (jsonb blob per layer + sha256 hash + timestamp). Retention: keep last 168 hourly (one week), last 30 daily (compacted), last 12 monthly (compacted). Nothing about this hits the deploy pipeline — purely a history layer for revert UI. Hash column dedupes idle hours (no row written if the resolved set hasn't changed since last snapshot, just last_seen_at bumped). At ~50KB per non-idle snapshot, the rolling window stays well under 50MB even after a year of daily edits.

Revert UI in /app/sorgente. A history panel lists snapshots chronologically with thumbnails (rendered preview of the captured composition). Click any → "Revert to this snapshot" → transactional replace of all Sorgente tables with that blob. Mirroring keeps working after revert; just don't auto-deploy. Designer reviews → presses Publish if they want it on prod.

Publish revert (production rollback). Every Publish row in arno_sorgente_publish_log carries the commit sha its workflow produced. The Library shows a Publish history list with "Revert this publish" buttons. Clicking dispatches arno-sorgente-rollback.yml with the prior good sha → release pipeline reverts → prod deploys predecessor. Designer can roll back any publish without leaving the ARNO UI.

Cross-environment promotion. prj-arno-sorgente exists in each env's DB independently (dev / test / prod). The promotion shape: maintainer works on test, validates, runs promote-sorgente.yml workflow (manual trigger) which copies test's Sorgente blob into prod's. Two-phase: dry-run that shows a diff in the workflow comment, then confirmed promote. Prod's own snapshot history captures the promote event so it's revertable like any other publish.

Layer 3 state preservation during live remount. Naive remount nukes form values, scroll, focus. The overlay solution: when a Layer 3 composition tree changes, instead of forcing a fresh <X /> mount, overlay diffs the prev and next composition trees and only re-renders the changed subtree (React reconciliation works locally — siblings keep their state). For props-only changes within the same component identity, no remount at all; for structural changes (added / removed node) only the affected subtree. Implementation cribs from react-reconciler's diff algo applied to our composition_instance shape; same approach Stately's editor uses for its live preview, same approach Tldraw uses for re-rendering edited frames.

Layer 3 type-drift guard. Every Publish that emits .tsx runs pnpm typecheck against the freshly written files inside the workflow before the actual commit/deploy steps run. If typecheck fails, the workflow halts, posts the TS error back to the Library as a "Publish blocked — substrate diverged" toast with a link to the failing file. Operator (me) opens a normal feature PR to align substrate, lands it, then re-presses Publish. Same shape Vercel uses to gate marketing- page generators on TS health.

§10 — Phases (delivery order)

Layer 1 (tokens) gets a usable shape in Phases A-C. Layer 2 (variants) joins after the variant editor lands (ADR 0044). Layer 3 (structural JSX) is the deep work — Phases G-J. Each phase is independently shippable; nothing later breaks anything earlier.

Phase A — token export + static layer (~1 day)

  • Add project.is_arno_sorgente column + migration.
  • tools/export-arno-sorgente/run.mjs reads DB, emits arno-sorgente.css.
  • apps/web imports arno-sorgente.css after Foundation tokens.
  • Create prj-arno-sorgente on test; seed with current Foundation defaults (the empty-override case produces a file with zero rules).

Phase B — manual publish (~0.5 day)

  • POST /api/v1/projects/prj-arno-sorgente/publish endpoint, maintainer- only.
  • Library UI button for the Arno Sorgente project.
  • .github/workflows/arno-sorgente-publish.yml triggered via workflow_dispatch.
  • arno_sorgente_publish_log audit table.

Phase C — linked test projects (Layer 1 mirror) (~1 day)

  • project.linked_to_arno_sorgente column + migration.
  • Server middleware on brand_seed/brand_binding/brand_font/ brand_icon/token_override/wcag_override mutations: if the project is linked, mirror into prj-arno-sorgente in the same transaction.
  • Maintainer-only toggle UI for the flag.

Phase D — runtime overlay (Layer 1 live) (~1-2 days)

  • SSE endpoint /api/v1/projects/prj-arno-sorgente/events.
  • apps/web/src/dev-tools/arno-sorgente-live/ArnoSorgenteLive.tsx.
  • Mount in apps/web/src/app/app/layout.tsx, gated to maintainer.

Phase E — LayoutInspector hooks into linked project (~0.5 day)

  • When the maintainer is in a linked-project context, LayoutInspector edits write to token_override via API instead of localStorage.
  • LocalStorage path stays the default for non-linked context.

Phase F — Component variant mirror (Layer 2) (~1-2 days, depends on ADR 0044 landing)

  • component_meta_override (or equivalent) gets the same mirror middleware as Layer 1.
  • Export adds arno-sorgente-variants.ts to its output.
  • Variant resolver loads from the new module at app boot.
  • Live overlay extends to swap the resolver's module for the linked project's values.

Phase G — Capture ARNO's own pages (~2 days)

  • Run the existing capture-v3 pipeline (ADR 0030) against ARNO itself — walk the React tree of /app/library, /app/workflow, the navbar, the sidebar, the brand panel; emit composition_instance rows for each.
  • One-shot script that, given a list of route paths, drives the capture for Arno Sorgente. Re-runnable: lossless re-capture on demand.
  • Verify by mounting the captured tree in a sandbox route and comparing pixel-by-pixel against the live route.

Phase H — Composition mirror + live remount (Layer 3 live) (~2-3 days)

  • captured_page / composition_instance get the same mirror middleware.
  • Live overlay subscribes to composition changes; when the linked project mutates a tree, the relevant subtree in the running ARNO remounts from the new composition.
  • Test: I drag a button in the linked project's Library — the actual Library page I'm looking at remounts the new arrangement within ~1s.

Phase I — Codegen for Layer 3 publish (~3-4 days)

  • Codegen reads composition trees from Arno Sorgente, writes .tsx files under apps/web/src/app/... and apps/web/src/components/....
  • Banner comment + CI guard: hand-edits to generated files fail PR.
  • Publish endpoint runs codegen as part of the workflow; commit + push; release deploys.
  • Determinism test: codegen run twice on the same composition tree produces byte-identical files.

Phase J — Substrate boundary lint (~1 day)

  • CI rule: certain paths (apps/api/, packages/db/, dev-tools, etc) are off-limits to codegen. PRs whose generated diff touches them fail.
  • Visual marker in the Library: components rendered from composition_instance are tagged "editable", substrate components are tagged "code-only". Designer sees the line before they try to drag something that wouldn't publish.

Total: ~14-16 days. Phases A-E ship a usable token-only self-hosting in ~5 days (the original §10 scope). Phases F-J extend that into full structural self-hosting and cost roughly the same again.

Layer 1 publish is fast and safe. Layer 3 publish ships real .tsx files; treat it accordingly — every Publish has the same risk profile as a normal release.

§11 — Open questions

  • Multi-environment: dev + test + prod each have their own database, so prj-arno-sorgente exists separately per env. Test stand experiments don't accidentally reach prod. Good. But: do we want dev to mirror prod's Arno Sorgente on schema reset? Or stay independent? Default: independent. Reset-time pull is a manual operation.
  • Rollback: if a publish breaks things, what's the revert path? Git revert of the commit produced by the publish workflow. Audit log carries the commit sha so revert is one-click from the maintainer surface. (Phase B add-on.)
  • Multiple maintainers: today there's one. If the team grows, the is_arno_sorgente / linked_to_arno_sorgente / publish gates need a role check beyond ownerId === 'usr-…specific…'. Stub for now, formalise when the team needs it.
  • SSE delivery on Cloudflare Workers: Workers don't natively hold SSE connections forever (idle timeout). If that becomes a problem, long-poll fallback is straightforward — same JSON payload shape, client-side abstraction hides the difference.

File map (for the implementer picking this up)

docs/adr/0049-arno-self-hosting.md                                — this file
packages/db/src/schema.ts                                         — add is_arno_sorgente, linked_to_arno_sorgente, arno_sorgente_publish_log
tools/migrate/drizzle/{timestamp}_arno_self.sql                   — migration
tools/export-arno-sorgente/{package.json,run.mjs}                    — Phases A/F/I exporter
packages/foundation/dist/arno-sorgente.css                           — Phase A output
packages/foundation/dist/arno-sorgente-variants.ts                   — Phase F output
apps/web/src/app/{generated routes}/page.tsx                      — Phase I codegen output (banner: do not edit)
apps/web/src/components/{generated}.tsx                           — Phase I codegen output
apps/web/src/app/layout.tsx                                       — import arno-sorgente.css
apps/web/src/dev-tools/arno-sorgente-live/{ArnoSorgenteLive,index}.tsx  — Phases D + H overlay
apps/web/src/dev-tools/arno-sorgente-live/ArnoSorgenteLive.css
apps/api/src/arno-sorgente.ts                                        — endpoints: full-state, events (SSE), publish, capture
apps/api/src/middleware/arno-sorgente-mirror.ts                       — Phase C + F + H mirror logic
.github/workflows/release.yml                                     — add export steps before prod backend deploy
.github/workflows/arno-sorgente-publish.yml                          — workflow_dispatch (Layer 1 + 2 + 3)
ci/codegen-guard.mjs                                              — Phase J: forbid hand edits to generated files

No arno-sorgente-snapshot.yml — publish is strictly maintainer-pressed, per §7.

Why this and not the alternatives

  • Branch-per-experiment (each design test = git branch): too heavy for a one-person design iteration loop. Branches are for code decisions, not for "is this padding 12 or 14".
  • Runtime-only (no static layer): every page load hits the DB, every user is dependent on backend health for the brand to render. Bad.
  • Static-only (no overlay): every keystroke takes 3-5 min to deploy. Defeats the point of the editor.

Hybrid (static + scoped runtime overlay) is what every mature design system platform that dogfoods does — Linear's appearance, Figma's dogfooded styles, Vercel's marketing site, GitHub Primer demo properties. We're doing the same shape with one twist: the maintainer's linked-project edits land in production without an explicit publish because that round-trip is the product. Everyone else still goes through Publish because they share the source-of-truth.