Agent reference — the long form behind `CLAUDE.md`
Status: reference — 2026-09-23. Class-2 durable reference (
docs/DOCS-POLICY.md).CLAUDE.mdis loaded into every agent session and subagent, so it keeps only the rules; this file keeps the rationale, history and walkthroughs those rules were distilled from (moved here verbatim whenCLAUDE.mdwas slimmed, 2026-09-23 —plans/HARNESS-MODERNIZATION.md§1.3). The full environment-variable catalogue lives inARCHITECTURE.md§4. When this file andCLAUDE.mddisagree, fix whichever is wrong; when either disagrees with code, code is the final authority.
1. Stack history
Section titled “1. Stack history”The old Supabase/Rust/Fly.io stack was removed 2026-06-26 (tag archive/pre-cleanup-20260626) — anything referencing Supabase, PostgREST, Foam/OPFS, or a Rust SSG is historical.
archive/ is a frozen pre-pivot snapshot — never update it. Docs can lag the code (the Clerk pivot landed 2026-07-03; docs written before that may still describe Neon Auth and sites-as-tenant). Code is the final authority, especially packages/schema/src/schema.ts.
2. The Bondlayer importer’s host (Render → Cloudflare Container)
Section titled “2. The Bondlayer importer’s host (Render → Cloudflare Container)”The Python Bondlayer importer is NOT archived. apps/bondlayer-worker/ is a Python service
(one Dockerfile) that turns a captured Bondlayer editor.json into Matriz Site JSON v1. Separate
from @matriz/import (which is the source-blind wizard side). src/parser.py →
element_mapper.py/style_transformer.py/cms_mapper.py/slot_mapper.py → api_writer.py.
Tests are bare python3 test_*.py in that dir, not pnpm.
- Live host: the
matriz-import-runtimeCloudflare Container (apps/bondlayer-worker/edge/, same Dockerfile as the retired Render service), deployed bydeploy-import-runtime.ymlon merge toapps/bondlayer-worker/. - Cutover: the
D:importer-containerscutover (plans/IMPORTER-CONTAINERS-MIGRATION-SPEC.md) flippedIMPORT_RUNTIMEto"container"onskyfall-api2026-09-02. - Render (
skyfall-keru.onrender.com) is no longer the importer host and no longer serves traffic; it is a retired fallback path only. Itswrangler.jsoncvars are kept only as an inert rollback path (IMPORT_RUNTIMEonskyfall-api,workers/src/lib/import-runtime.ts). Decommissioning Render itself (Pass F: suspend/delete the service, retire the threeonrender.comvars, rotateIMPORT_WORKER_TOKEN) is a separate open owner action — seeplans/OWNER-OPS.md. - A path that never existed: several docs written before 2026-07-26 pointed at
archive/2026-07-13-bondlayer-tooling/bondlayer-import/— that path has never existed; only the frozen JS xlsx converter sits underarchive/2026-07-13-bondlayer-tooling/.
3. CI and the pnpm ci:local gate
Section titled “3. CI and the pnpm ci:local gate”CI: GitHub Actions is LIVE again (2026-08-02) — pnpm ci:local remains the pre-push gate
Section titled “CI: GitHub Actions is LIVE again (2026-08-02) — pnpm ci:local remains the pre-push gate”Actions was dead 2026-07-24 → 2026-08-02 (payment failure); it now runs and its
results are REAL — check them on PRs and master. Two things only Actions covers:
the Workers integration suite (workers/test/integration.test.ts gates on a
real NEON_DATABASE_URL secret and self-skips locally — a local all-green does
NOT prove it), and the deploy workflows (deploy-import-runtime.yml and the
docs sites; deploy-api.yml/deploy-editor.yml are manual-only since
2026-09-23 because the Cloudflare Git integrations deploy those two). pnpm ci:local is still the pre-push gate for everything it covers. Actions stopped
again 2026-09-23 (spending limit / minutes exhausted); jobs now pick a runner
by tier (CI_CHECKS_RUNS_ON / CI_HEAVY_RUNS_ON / CI_DOCKER_RUNS_ON, secrets
on CI_RUNS_ON only) — D:ci-runner-tiers, OWNER-OPS § 2026-09-27.
pnpm ci:local IS the gate. It runs the same commands those workflows
declare, plus two things they never covered (db-guard, and the 400+ Python
importer tests). A pre-push hook runs it automatically, scoped to the paths
you changed — install once per clone:
pnpm hooks:installpnpm ci:local # everythingpnpm ci:local --changed # only suites whose paths changed vs origin/master; within frontend/workers, # only tests whose import graph reaches a changed file (+ source-reading # "scanner" tests) — scripts/vitest-scoped.shpnpm ci:local --pr # verify a checked-out PR like CI: --changed vs fresh origin/master # + the frontend build (+ importer image smoke if Docker runs)Bypass a single push with git push --no-verify (or SKIP_CI_LOCAL=1 git push).
test_importer_refinements.py needs pytest — pip3 install pytest — otherwise
that one file is skipped with a warning. test_members_from_xlsx.py needs
openpyxl (pip3 install openpyxl) and does NOT skip: without it the whole
gate stops at “python importer tests” with a ModuleNotFoundError, which reads
like a real failure. Both are pinned in apps/bondlayer-worker/requirements.txt
and installed by importer-ci.yml, so this is a local-environment gap only —
pip3 install -r apps/bondlayer-worker/requirements.txt covers both at once.
Deploys
Section titled “Deploys”Deploys do not go through Actions either. The Worker (skyfall-api) deploys
via Cloudflare Workers Builds (native Git integration, root directory
workers, NODE_VERSION=22 build variable — wrangler 4 needs Node ≥ 22 and the
repo’s .nvmrc pins 20 for the test matrix). The editor deploys via the Pages
project’s own Git integration. The importer deploys via deploy-import-runtime.yml
(§2).
The workflows
Section titled “The workflows”CI (.github/workflows/) — LIVE since 2026-08-02 — see “CI: GitHub Actions is LIVE again” above; pnpm ci:local is still the pre-push gate. 10 workflows: frontend-ci.yml, workers-ci.yml, packages-ci.yml, importer-ci.yml, db-guard.yml (schema-drift gate) are checks; deploy-docs-product.yml, deploy-docs-dev.yml, deploy-import-runtime.yml deploy on push to master (path-filtered, + manual dispatch); deploy-api.yml (Worker) and deploy-editor.yml (Pages) are manual-dispatch only since 2026-09-23, because Workers Builds / the Pages Git integration deploy those on every master push. All need the CLOUDFLARE_API_TOKEN/CLOUDFLARE_ACCOUNT_ID repo secrets.
4. Deployed hosts and frozen skyfall* names
Section titled “4. Deployed hosts and frozen skyfall* names”Deploy — Editor: Cloudflare Pages project skyfall (frozen legacy id), live at https://app.matriz.design (custom domain; underlying Pages host skyfall-b5u.pages.dev, prod branch master). API: Worker skyfall-api (frozen legacy id) at https://api.matriz.design (custom domain; legacy host skyfall-api.simaomartinez.workers.dev — the workers.dev subdomain is simaomartinez, not b5u, CF rejected the rename). The deployed Cloudflare resource names stay skyfall* — CF can’t rename Workers/Pages/R2 in place, so they’re frozen legacy identifiers (see the rebrand decision).
Similarly, the product was renamed Skyfall → Matriz (rebrand decision). Source, docs, packages (@matriz/*), and wire markers (data-matriz-*, matriz-form-*, X-Matriz-Signature, sk_matriz_) are all rebranded, but the deployed Cloudflare/Neon resource names remain skyfall* as frozen legacy identifiers (Worker skyfall-api, Pages project skyfall, R2 buckets skyfall-assets/skyfall-yjs-snapshots, Neon project Skyfall-V2) — CF/Neon can’t rename these in place. The DB table skyfall_config and the docs/architecture/SKYFALL-SITE-JSON-V1.md spec filename are likewise frozen. Custom domains app.matriz.design (editor) / api.matriz.design (API) front the frozen hosts.
5. UI Lab — visual inspection without auth or DB (/ui-lab)
Section titled “5. UI Lab — visual inspection without auth or DB (/ui-lab)”Use this to see editor UI changes and catch layout/overflow errors — it renders the REAL
editor chrome (TopBar, AppSidebar all modes, DOM tree, token editor, PropertiesPanel +
PropertyEditorShell) on deterministic fixtures. frontend/src/pages/ui-lab/: stores seeded
synchronously, /api/* served by a path-scoped window.fetch mock (token CRUD is interactive,
nothing persists), canvas iframe replaced by a placeholder, per-panel error boundaries so one
broken panel can’t hide the rest. Dev builds only.
Agent workflow: the app needs VITE_CLERK_PUBLISHABLE_KEY to boot but the lab never uses a
session — a fake key is fine: echo "VITE_CLERK_PUBLISHABLE_KEY=pk_test_$(echo -n 'x.clerk.accounts.dev$' | base64 -w0)" > frontend/.env.local
(gitignored). Then pnpm --filter ./frontend dev and screenshot http://localhost:3001/ui-lab
with Playwright/Chromium; ignore console errors from the clerk-js CDN load. Fixtures are
deliberately adversarial (long oklch()/clamp() values, long token names) — keep them that way;
they exist to expose truncation and overflow bugs. When you add a panel or API call to the
editor, give the lab’s mock (mock-api.ts) a matching route (unmatched GETs return [], which
can silently overwrite seeded store state on mount refetches — that’s the failure mode to check).
6. Save path detail
Section titled “6. Save path detail”Edit → Zustand (optimistic) → save-v2 sink (frontend/src/lib/save-v2/, installed in main.tsx) → debounced POST /api/nodes/batch?target_id=… (upsert-by-id + cascade-delete, slot_values per-key merge)- The tenant scope param on the batch flush is mandatory — omitting it 400s at the access middleware and silently loses edits (the D50 bug).
- Pending + in-flight edits mirror to an IndexedDB crash journal, replayed on next editor boot (
recoverJournaledSavesin App.tsx). - The legacy
OperationQueuesave path is gone. Never add a parallel write path; go through PropertyCommands → the sink. - Text slots write through
slot_valuesvia the ordinary attributes path (PropertyCommands.updateSlotValues→NodeWriteAPI.update). There is exactly one authoritative path — the Y.Text CRDT seam that used to sit in front of it (routeTextWrite) was deleted (D:ytext-deletion): it never had a caller construct a session, so it always returned false and the REST path always ran. Seedocs/design/COLLAB-YTEXT-ACTIVATION.mdfor why, and for what a future real-time text feature should look like instead.
7. Publish detail
Section titled “7. Publish detail”POST /api/publish/:siteId → DO flush → @matriz/site-render compiles dom_nodes to static HTML (inlined DTCG token CSS, responsive/state rules) → R2 → served publicly at /published/:siteId (with PUBLISH_ROOT_DOMAIN set, that path route 301s to the target’s canonical host instead). Published pages ship a strict CSP — script-src 'none' unless the page uses the sha256-hash-pinned interactions runtime; this invariant is CI-gated (published-csp-invariant.test.ts — script-src is forever 'none'|hashes). Every third-party origin a published page can touch is enumerated in packages/site-render/src/third-party-ledger.ts (inverse-guarded tests) — its diff is the review surface when adding origins. No runtime JS/CSS dependencies; rendering changes go in packages/site-render, never in just one consumer.
- Embeds:
element_type 'embed'— 8-provider allowlist-anchored registry (embed-registry.tsin@matriz/site-render; usenormalizeEmbedUrl). It is the sole iframe producer; a rawiframeelement still coerces todiv. Publish accumulates per-pageframe-srcorigins into R2customMetadata.cspFrameSrc(besidecspScriptHash); serve emits them into the CSP. - Custom code:
element_type 'custom_code'publishes as an opaque-origin sandboxedsrcdociframe —sandbox="allow-scripts allow-forms"hardcoded;allow-same-originis unrepresentable in the emitter and CI-gated (sandbox-gate.test.ts). - Edge analytics:
servePublishedAssetrecords cookieless page views to the optionalPUBLISH_ANALYTICSAnalytics Engine binding (never affects serving);GET /api/targets/:id/analyticsqueries it (CF_ACCOUNT_ID+CF_ANALYTICS_API_TOKEN); dashboard surface isTrafficCard(ProjectCard menu → Dialog).
8. Stores
Section titled “8. Stores”editorStore (DOM trees, selection, viewport), pagesStore, cmsStore, themeStore. Always subscribe via selectors: useEditorStore(s => s.selectedElementId). (There is no presenceStore — it was deleted with the presence UI; nothing in frontend/src/ references it.)
9. Decisions log — the full rule
Section titled “9. Decisions log — the full rule”- History (2026-06-25 through 2026-09-27) is frozen and chunked under
decisions-logs/archive/(D:decisions-logs) — split byscripts/decisions/split.mjs, ~25 entries per chunk, byte-exact (its_manifest.jsonpins asourceSha256;decision-number-guard.test.tsre-checks it every run).decisions-logs/INDEX.mdis generated from the archive (scripts/decisions/index.mjs) — grep that instead of a chunk file, and neverReada chunk whole.DECISIONS.mdat the repo root is now a ≤30-line stub kept only so old links resolve. - Why this replaced D-number claiming: the old scheme (claim a number late, at the final
pre-merge sync, cite by slug until then) raced repeatedly anyway — parallel sessions all appended
to one file, so a number picked at merge time could already be stale by the next merge, minutes
later (it happened 3× in one branch, D68→D69→D72, and twice more the week the original guard was
written — see its old header, preserved in
decisions-logs/archive/). Four historical duplicates from that era — D41/D50/D51/D52 — are grandfathered permanently; they predate the fix and are cited too widely to renumber. - The fix: one file per decision, slug-only, forever. A new decision is
decisions-logs/entries/<YYYY-MM-DD>-<slug>.md(template:entries/_TEMPLATE.md, heading# D:<slug>), cited asD:<slug>— no number is ever claimed again. Two parallel PRs adding different decisions now touch different files: nothing to conflict over, nothing to renumber. Old, already-merged decisions keep their number and are cited bare (D50,D56) as always.workers/test/node/decision-number-guard.test.tsfails the build on a slug collision (with anotherentries/*.mdor an archived decision), a filename/heading mismatch, or any new numbered## D<n>heading anywhere — that scheme doesn’t get resurrected to make a build pass.
10. Gotchas — the long form
Section titled “10. Gotchas — the long form”frontend/src/lib/postgrest.tsis a legacy-named barrel overlib/api/— there is no PostgREST anymore.- Canvas ↔ iframe talk via
postMessage(elementClicked,inlineContentChanged,setSiteCss, …); selection debounced 50 ms; validateevent.origin. workers/src/worker.tsvsindex.ts: worker.ts adds theagents-based MCP DOs and is the deploy entry; tests import index.ts (miniflare lackscloudflare:email). Don’t moveagentsimports into index.ts.- Uploads: R2 via
/api/assets/sign;cdn_urlis derived server-side (D38) — never construct it client-side. - MCP
write_componentis slated for deprecation (Intent-Driven Rendering, D51) — don’t build new features on it. - The live editor canvas renders
embed/custom_codenodes live: embeds mount their real iframe on the canvas;custom_coderenders click-to-run in edit mode (a Run button mounts the sandboxed iframe on demand) and live in interactive/preview mode. The iframe markup is produced in React by@matriz/site-render(renderEmbeddedNodeHtmlinfrontend/src/lib/canvas-embed-render.ts→serializeNodeTree, attached as_matrizRenderedHtmlineditorStore.updateAllPreviews) and mounted by preview.html — there is no ES5 fork ofnormalizeEmbedUrl/escapeSrcdoc(the G3 parity gate still holds).custom_codestays opaque-origin sandboxed (allow-scripts allow-forms, neverallow-same-origin) even on the canvas. In edit mode the child iframe ispointer-events: noneso canvas selection still works. db/*.sqlsnapshots and older docs can be stale; verify againstpackages/schemaand the route files.pnpm db:migrateaborting with... already exists? Prod carries TWO__drizzle_migrationstables —drizzle.(the real one drizzle-kit reads) andpublic.(a phantom nothing reads), and the migrator’s cursor is just the single row with the maxcreated_at, so one row written to the wrong table re-runs that migration and every one after it. Hand-applies MUST schema-qualifydrizzle."__drizzle_migrations". Diagnosis + repair recipe:db/MIGRATIONS.md→ “Two ledger tables”.data-member-visibleis a whitespace-separated, AND-combined token list, and an unknown token HIDES (D:member-auth-blocks):authed|signed-in,anon|signed-out,active,pending,role:<r>,!role:<r>(not a member with that role — signed-out included;D:bondlayer-member-state),provider:google|magic-link,join:open|invite|approve. Imported Bondlayer_statesession gates (UserLogged/UserRole/…) become these tokens (importermember_state_mapper.py, render-time legacy normalizer + repair libworkers/src/lib/member-state-migrate.ts, one shared table inmember-state-conditions.ts) —_stateis never evaluated as a CMS field. The grammar lives inMEMBER_RUNTIME_SOURCE(packages/site-render/src/interactions-runtime.ts) AND is mirrored by hand infrontend/public/preview.html(applyMemberPreview) — change both, and keepmembers-library.verify.test.ts(which validates every authored value) green.provider:/join:tokens are always visible in the editor (no config fetch on the canvas).- PGlite suites boot from ONE migrated snapshot per run, not 38 migrations per file (
D:pglite-snapshot).vitest.node.config.ts’s globalSetup migrates once and dumps the data dir toworkers/node_modules/.cache/pglite-snapshot/<hash>.tar(hash = everydrizzle/*.sql+ PGlite version);makePgTestDb()loads it in ~1–2 s instead of initdb + migrations (~9 s). Editing a migration invalidates it automatically.pglite-snapshot-fidelity.test.tspins that the loaded catalog equals a freshly migrated one — if you change how migrations are applied intest/node/helpers/pg-test-db.ts, bumpSNAPSHOT_FORMATinhelpers/pglite-snapshot.ts.makePgTestDb({ stopBefore })still migrates in-process (it needs a partial schema). A database made inside a test orbeforeEachis closed automatically when that test finishes (onTestFinished); never keep one across tests. Unclosed per-test PGlite heaps (~250 MB each) got forks OOM-killed (“Worker exited unexpectedly”) before this. - Importer tests auto-collect — never hand-list them. The 50
apps/bondlayer-worker/src/test_*.pyfiles are barepython3, and each ends withsys.exit(run(globals()))(_testkit.py), which collects every module-leveltest_*in definition order. That call must stay at end of file: a test defined below it does not exist when it runs. This replaced hand-maintained call lists that left 9 tests dead while the files still printed green — the printed count is of assertions (check()), not tests, so a dead test never showed. Collecting zero tests is now a hard failure; the two files whose assertions live inmain()/at import time keep their own entry points. custom_head_css/custom_body_jsare LIVE, not inert passthrough. Bondlayer’s project-levelwebHeadStyles/webBodyScriptsland in target settings and are emitted at publish as<style data-matriz="custom">(workers/src/lib/page-render.ts:378, and the standalone renderer inpackages/site-render/tools/render-site-json.ts:138). So imported custom CSS works — it is simply invisible to the property editor and to the render engine’s own cascade reasoning (see the note inpackages/site-render/src/text-baseline.ts:107). On both surfaces it comes AFTER Matriz’s floor rules (base/font-inherit/sizing-reseton publish, preview.html’s static<style>on the canvas), so it wins specificity ties against them (D:site-css-after-floors,site-css-order-parity.test.ts). Anything that migrates rules out of that blob MUST remove them from it; leaving them in double-applies.- A text node’s words live in
slot_values.text, notcontent(D:text-slot-canonical). Every renderer reads the slot first;dom_nodes.contentis a mirror a DB trigger keeps (workers/drizzle/0044_text_slot_canonical.sql: slot → content on insert/slot change, a content-only UPDATE is copied into the slot; skips component instances and CMS-bound slots). Write the slot (PropertyCommands.updateSlotValues/updateContent, or the Dev MCP’supdate_text_node/replace_text_across_site) — never raw SQL ondom_nodes.content. The batch save route never overwritescontenton a slot-owned row (a full-row flush can carry a stale copy). data-bl-nameis an id by intent, a class by mechanism. Bondlayer designers rename elements to target them from custom code, and MATRIZ mirrors the field as the element name. But across the captures 31 of 46 referenced names match multiple nodes on purpose (bgblur33,square25) — fanning out is the author’s intent, not a bug. The exception is unrenamed defaults:Textmatches 1538 nodes, so a rule reaching a default name is almost always a mistake. Never assume a name is unique.
Appendix — CLAUDE.md sections as they read before the 2026-09-23 slim
Section titled “Appendix — CLAUDE.md sections as they read before the 2026-09-23 slim”CLAUDE.md now carries condensed versions of these. The full wording is kept here so no fact was
lost; the importer, CI and deploy rows of the architecture table are in §2–§4 above.
Doc map (full descriptions)
Section titled “Doc map (full descriptions)”| Doc | Owns |
|---|---|
STATUS.md |
Current state — what works, what’s open. The daily entry point. |
DECISIONS.md |
Why — append-only architectural decisions (D1…). |
ARCHITECTURE.md |
Runtime topology, auth flow, DB roles, how to add routes. |
docs/architecture/OVERVIEW.md + TERMINOLOGY.md |
Durable system architecture + canonical vocabulary. |
plans/OWNER-OPS.md + plans/LOOSE-ENDS-EXECUTION-SPECS.md |
Owner escalation ledger + S-series execution specs (the old pivot-plan/INDEX roadmap is archived). |
docs/DOCS-POLICY.md |
How docs are updated, banner rules, archive procedure, the two Starlight docs sites (apps/docs-product → docs.matriz.design, apps/docs-dev → dev.matriz.design). |
db/MIGRATIONS.md |
Drizzle migration procedure. |
docs/architecture/BONDLAYER-IMPORT-PIPELINE.md + BONDLAYER-EDITOR-JSON-CAPTURE.md |
Bondlayer import: how a site is captured and transformed. |
docs/intent-system/MCP-CONNECT-RUNBOOK.md + MCP-OAUTH-SETUP.md |
Connecting external agents over MCP — credentials/URLs runbook, then the OAuth door’s setup + scope mechanics. |
docs/architecture/COLLECTION-CONTROLS.md |
The data-mf-* collection-control vocabulary (search/filter/sort/pager): which surface honours each marker, why edit mode is inert but Preview is live, and how to debug an imported control that “does nothing”. |
docs/Research/BONDLAYER-*.md |
Competitive capture of Bondlayer’s authoring surfaces (research, not spec) — see the corpus map in plans/BONDLAYER-FOLLOWUPS-WORKSTREAM.md. |
design/figma/FIGMA-SYNC.md |
Figma mirror of the editor UI (file bZ17CtfEJ11rcV8UHNGKvh) — sync contract + node/variable ID map. Any change to editor tokens (frontend/src/index.css, tailwind.config.js), chrome layout, or lucide icons must be synced BOTH ways via the figma-editor-sync skill (.claude/skills/figma-editor-sync/). Code is source of truth; edit Figma nodes in place by ID, never recreate. |
Architecture table
Section titled “Architecture table”| Layer | Stack |
|---|---|
| Frontend | React 19, Vite 7, TypeScript strict, Zustand 5, shadcn/ui + Radix, Tailwind, Clerk (@clerk/clerk-react), TipTap |
| API | Cloudflare Workers, Hono, Drizzle ORM over Neon HTTP driver (workers/src/) |
| DB | Neon Postgres — canonical schema in packages/schema/src/schema.ts (43 tables); workers/src/db/schema.ts is a re-export shim (keep importing from it in Workers code, but edit the package) |
| Storage | R2 — assets, published HTML, Yjs snapshots |
| Realtime | Durable Objects — IntentRoomDO (IDR poke channel, D60/D61) |
| Shared packages | @matriz/schema, @matriz/site-render (renderer shared by editor preview AND publish — preview↔published parity), @matriz/import (source-blind import adapters) |
Auth + tenancy (Clerk B2B2C + Split-Glass, live since 2026-07-03)
Section titled “Auth + tenancy (Clerk B2B2C + Split-Glass, live since 2026-07-03)”- Clerk is the identity provider. Frontend:
ClerkProviderinmain.tsx,/sign-in+/sign-uproutes,AuthGuard, session token attached asBearerbylib/api/client.ts. There is no authStore — use Clerk hooks. - The Clerk org IS the project.
users.id/projects.idare Clerk text ids (user_…/org_…)./api/webhooks/clerk(Svix-verified) syncs users/orgs/memberships into local projections. - Split-Glass hierarchy:
projects(tenant boundary) →targets(deployable site, formerlysites). Worker guards:authMiddleware(@clerk/backendverifyToken; setsuserId,orgId) →projectAccessMiddleware(org-claim fast path, elseproject_usersjoin; setsprojectId/targetId). - RLS: Clerk-claims policies in
db/migrations/20260703_clerk_rls_baseline.sql;getScopedDB(c)routes through the tenant role only whenRLS_ENFORCED+ the authenticated URL secret are set — until then app-layer middleware is the isolation.getServiceDBis BYPASSRLS: use only for provisioning/publish/webhooks and annotate why.
Naming drift
Section titled “Naming drift”“Site” survives everywhere in code — routes (/editor/:siteId), site_pages, siteId variables, frontend/src/lib/api/sites.ts — but the API scope keys are now target_id/project_id and sites.ts points at /api/targets. A “site” in frontend code is a target. Don’t “fix” this wholesale; follow the convention of the file you’re in.
Frontend routes
Section titled “Frontend routes”/ Dashboard /import Universal import wizard/editor/:siteId Visual editor /sign-in, /sign-up Clerk (outside AuthGuard)/cms/:siteId[/:collectionId] CMS /ui-lab UI Lab (dev only, outside AuthGuard)