MATRIZ — Architecture Overview
Repaired 2026-08-22 (doc reset,
D:doc-reset). For what works right now seeSTATUS.md; for why decisions were made seedecisions-logs/(grepINDEX.md); for open owner work seeplans/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.
The stack (post-pivot)
Section titled “The stack (post-pivot)” 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)Packages (pnpm workspace)
Section titled “Packages (pnpm workspace)”| 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. |
Data plane
Section titled “Data plane”- 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.tsis a thin re-export shim (the import path for the Workers app,drizzle.config.ts, and thedb-guarddrift 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).
Request security
Section titled “Request security”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).
The editor write path
Section titled “The editor write path”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.
Publish pipeline (live; D28)
Section titled “Publish pipeline (live; D28)”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.
CI / deploy
Section titled “CI / deploy”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 projectskyfall, 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 deploythe Worker (skyfall-api). Manual only: the Cloudflare Workers Builds Git integration (root directoryworkers) 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 tovars.CI_RUNS_ON || 'ubuntu-latest'; secret-bearing jobs read onlyCI_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,.nvmrc20) so Cloudflare’s build is reproducible.