- 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:
- I want to change the look of a button in ARNO itself.
- 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. - 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:
- Static layer — baked into the bundle.
packages/foundation/dist/tokens.cssplus the newdist/arno-sorgente.cssexported on each release. This layer ships with the app, works under SSR, works offline, and is what every user sees by default. - 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:
| Concept | Storage | Mechanism |
|---|---|---|
| Brand seeds (primary / secondary / tertiary) | brand_seed | ADR 0038 |
| Semantic ↔ ramp bindings | brand_binding | ADR 0038 |
| Custom fonts | brand_font | ADR 0038 |
| Brand icons | brand_icon | ADR 0041 |
| Per-token overrides (padding, margin, font-size, color, etc) | token_override | ADR 0045 |
| WCAG override decisions | wcag_override | ADR 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 tobrand_seed/brand_binding/brand_font/brand_icon/token_overrideon this project is mirrored toprj-arno-sorgentein 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:
- Read
prj-arno-sorgenteoverrides through the existing API client (server- side, with a service token). - Apply them against the Foundation v1 baseline tokens to compute a
resolved set: same shape as
packages/foundation/dist/tokens.cssbut with Arno Sorgente values pinned. - Emit:
packages/foundation/dist/arno-sorgente.css— CSS-var overridespackages/foundation/dist/arno-sorgente.json— machine-readable snapshot (used for diffing / debugging)packages/foundation/dist/arno-sorgente.meta.json—{ exportedAt, sourceProjectId, sourceCommitSha }for audit
- 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
fiapps/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=1or session storage flagarno-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:
- Snapshots the current Arno Sorgente state.
- Triggers a GitHub Actions
workflow_dispatchon.github/workflows/arno-sorgente-publish.yml. - 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_logtable — 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_instanceinto 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_overrideor 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.tsximports 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.tsxoverlay — 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 = truerows. 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_sorgentecolumn + migration. tools/export-arno-sorgente/run.mjsreads DB, emitsarno-sorgente.css.apps/webimportsarno-sorgente.cssafter Foundation tokens.- Create
prj-arno-sorgenteon 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/publishendpoint, maintainer- only.- Library UI button for the Arno Sorgente project.
.github/workflows/arno-sorgente-publish.ymltriggered viaworkflow_dispatch.arno_sorgente_publish_logaudit table.
Phase C — linked test projects (Layer 1 mirror) (~1 day)
project.linked_to_arno_sorgentecolumn + migration.- Server middleware on
brand_seed/brand_binding/brand_font/brand_icon/token_override/wcag_overridemutations: if the project is linked, mirror intoprj-arno-sorgentein 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_overridevia 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.tsto 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; emitcomposition_instancerows 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_instanceget 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
.tsxfiles underapps/web/src/app/...andapps/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-sorgenteexists 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 filesNo 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.