Skip to content

MATRIZ — Architecture Overview

Repaired 2026-08-22 (doc reset, D:doc-reset). For what works right now see STATUS.md; for why decisions were made see decisions-logs/ (grep INDEX.md); for open owner work see plans/OWNER-OPS.md.

The Clerk B2B2C pivot (D51) landed 2026-07-03: Clerk auth + Split-Glass (Projects/Targets) is the running system described below. The old pivot plans are archived under archive/2026-08-22-doc-reset/.

MATRIZ is a visual website builder: a browser editor where users compose pages from a tree of elements + reusable components bound to a CMS, then publish the result as a static site.

Editor SPA (frontend/) ──fetch /api/*──▶ Workers API (workers/, "skyfall-api")
React 19 + Vite + pnpm Hono router
Cloudflare Pages │
┌───────────────┬─────────┼───────────┬──────────────────┐
▼ ▼ ▼ ▼ ▼
Neon Postgres Durable Object R2 @matriz/site-render
(Drizzle) IntentRoomDO assets compiler + tokens +
43 tables (IDR poke chan.) + pub html-serializer (shared)
Package Dir Role
frontend frontend/ Editor SPA (React 19 + Vite), deployed to Cloudflare Pages. Talks to the API over fetch /api/*.
skyfall-api workers/ API + data plane: Hono routes, Drizzle/Neon access, R2 access, the publish pipeline. Deployed as a Cloudflare Worker.
@matriz/site-render packages/site-render/ Runtime-agnostic rendering core (component compiler + token resolver + static HTML serializer), shared by the editor preview and the publish pipeline so both render identically.
@matriz/schema packages/schema/ Canonical Drizzle table model + row DTOs ($inferSelect/$inferInsert) + slot/value contracts, shared by the Workers app and the import pipeline (D37).
@matriz/import packages/import/ Source-blind import adapters (CSV/JSON/XLSX/Webflow/Bondlayer/site-json) behind one wizard contract.
@matriz/email-render packages/email-render/ Email template rendering shared by the marketing designer and campaign send.
  • Neon Postgres via Drizzle ORM. The canonical data model is @matriz/schema (packages/schema/src/schema.ts, 43 tables) (D37) — projects/targets, dom_nodes, components, design systems/tokens, content types/items, site pages, assets, builds, orgs/users, …; workers/src/db/schema.ts is a thin re-export shim (the import path for the Workers app, drizzle.config.ts, and the db-guard drift check). The live DB also carries a few orphaned pre-pivot tables pending a drop (DECISIONS D17).
  • R2 for binary assets (ASSETS_BUCKET) and as the static host for published sites (published/<siteId>/…).
  • Durable Object IntentRoomDO (workers/src/do/intent-room.ts): per-project poke channel for the IDR pending-intents inbox (D60/D61) — no storage, relays “a new intent exists” to live editor sessions.

There is no Supabase, no PostgREST, no Rust backend — all removed in the pivot. The Workers API (Hono) is the sole data layer (DECISIONS D25/D26).

Clerk is the identity provider (live since 2026-07-03; the Clerk org IS the project). Every tenant-scoped route runs authMiddleware (@clerk/backend verifyToken; sets userId/orgId) then projectAccessMiddleware (org-claim fast path, else a project_users join; sets projectId/targetId). Postgres RLS policies exist (db/migrations/20260703_clerk_rls_baseline.sql) and getScopedDB routes through the tenant role only when RLS_ENFORCED + the authenticated URL secret are both set — until that is verified in prod, app-layer middleware is the effective isolation (see db/RLS-ACTIVATION.md and the STATUS punchlist).

There is one save path, for structural writes and text alike: the store-owned debounced save sink (frontend/src/lib/save-v2/) → POST /api/nodes/batch (Drizzle upsert-by-id + cascade-delete). slot_values still merges per-key rather than replacing the whole column (D27) — that started as protection for a Y.Text DO projection which never ran a single write in production and was deleted (D:ytext-deletion); the per-key merge itself is unrelated to that and stays, since a whole-object replace would still lose concurrent edits to different keys on the same node. The VITE_SAVE_V2 flag was flipped + retired and the legacy OperationQueue purged (D24); a since-deleted Y.Text CRDT seam briefly existed as a second, always-inert path for text — see docs/design/COLLAB-YTEXT-ACTIVATION.md for the full history.

POST /api/publish/:siteId: force-flush the site DO → read published dom_nodes/site_pages/design_tokens/content_items → compile each page to static HTML via @matriz/site-render (component instances compiled; CMS repeaters expanded from the persisted data_binding — D29) with inlined token CSS → write the bundle to R2. Published sites serve publicly at GET /published/:siteId[/path]; with the PUBLISH_ROOT_DOMAIN var set, that path route instead 301s to the target’s canonical host (custom domain → platform subdomain).

A page’s own internal links are resolved against the SAME base as its sitemap/feed/OG URLs (resolvePublishBase → SiteRenderContext.basePath, D:publish-base-path): bare paths for a target with a canonical host, /published/<targetId>/… for one without — on the legacy door a bare /about would otherwise resolve against the API origin’s root, which is not the site, and only the homepage would load. The host door strips a stale /published/<targetId> prefix and 301s, so docs baked before a domain existed keep working until the next publish.

Published pages ship a strict CSP whose per-page inputs travel as R2 customMetadata: cspScriptHash (hash-pinned interactions runtime; script-src is forever 'none'|hashes — CI-gated) and cspFrameSrc (embed-provider origins). Iframes come only from the 8-provider embed registry (embed-registry.ts); custom_code nodes publish as opaque-origin sandboxed srcdoc iframes (allow-scripts allow-forms hardcoded). The full third-party origin surface is pinned in packages/site-render/src/third-party-ledger.ts. servePublishedAsset also records cookieless page views to the optional PUBLISH_ANALYTICS Analytics Engine binding.

Live time, schedules and symbols (D:recurring-structures, waves 0–1)

Section titled “Live time, schedules and symbols (D:recurring-structures, waves 0–1)”

Publish now carries a second class of runtime-settled content alongside audience gating: temporal rules — a visibility rule or repeater/table pre-filter keyed on a date, @now, or “the current period of a schedule.” These are marked at publish (resolved once against the build clock, for the no-JS default) and settled live in the visitor’s browser against a server-corrected clock fetched from the public GET /api/m/:targetId/time route — the CSP invariant is untouched, since the settling logic rides the same single hash-pinned interactions runtime every other client-side behavior already uses. Everything that must actually be enforced rather than merely shown — form submission windows, a member’s ability to edit their own item, a symbol resolved for a write — stays resolved server-side on every request, never in the browser.

The pattern this unlocks is a recurring structure: a container collection carrying a schedule field (ordered, dated periods with on/off gates and per-period data), a site symbol (@current.<name>) naming “the current one” of that container, and a participation collection whose forms derive the current symbol/period at ingest and whose identity (upsertScope) makes re-submission replace a member’s row instead of duplicating it. See ARCHITECTURE.md §8 for the mechanism and docs/architecture/TERMINOLOGY.md for the vocabulary — including the naming collision between this “symbol” and the pre-existing symbols reusable-component table, which is a different feature.

  • frontend-ci.yml — lint + typecheck + build + test (frontend).
  • workers-ci.yml — lint + typecheck + tests (unit + env-gated live-Neon integration).
  • packages-ci.yml — typecheck + lint + test for the @matriz/* workspace packages (schema, site-render).
  • db-guard.yml — db:check + Drizzle schema-drift gate.
  • deploy-editor.yml — build + wrangler pages deploy frontend/dist (Pages project skyfall, a frozen legacy id; live at app.matriz.design). Manual only (workflow_dispatch): the Pages project’s own Git integration deploys on every master push.
  • deploy-api.yml — wrangler deploy the Worker (skyfall-api). Manual only: the Cloudflare Workers Builds Git integration (root directory workers) deploys on every master push.
  • Jobs pick a runner by tier (CI_CHECKS_RUNS_ON / CI_HEAVY_RUNS_ON / CI_DOCKER_RUNS_ON, each falling back to vars.CI_RUNS_ON || 'ubuntu-latest'; secret-bearing jobs read only CI_RUNS_ON) — D:ci-runner-tiers, plans/OWNER-OPS.md § 2026-09-27.
  • deploy-docs-product.yml / deploy-docs-dev.yml — build + deploy the Starlight doc sites (apps/docs-product → docs.matriz.design, apps/docs-dev → dev.matriz.design).
  • Toolchain pinned (packageManager: pnpm@10, .nvmrc 20) so Cloudflare’s build is reproducible.