Hard Architectural Rules
Full reference for load-bearing constraints enforced by ESLint, CI, or runtime behaviour. CLAUDE.md carries a critical-violations summary; this document has the full detail.
- SDK is the only plugin↔platform contract. Plugins MUST NOT import from
runtime/src. ESLint enforces this (established in Task 0.3.3, verified in Task 0.3.8). Plugins usepackages/sdkonly. - Reusable UI/UX capability ships from the design system, not from plugins. Interaction hooks, overlay surfaces, secondary headers, motion, and controls belong in
packages/ui(or the runtime shell when they are shell chrome); plugins — including first-party ones like Sovereign Tasks — only consume them. A fix discovered inside a plugin is designed as apackages/uiaddition plus a thin adoption change in the plugin, never as a plugin-local implementation "to be promoted later". React-coupled UI utilities live in@sovereignfs/ui, not@sovereignfs/sdk(the SDK stays a framework-lean capability contract). See "Design principles" indocs/design-system.md. @sovereignfs/sdkis a types-first contract with zero runtime dependencies (RFC 0023, Task 0.5.21).packages/sdkdoes not import@sovereignfs/dbor@sovereignfs/mailer. Implementations are registered by the runtime at startup viaprovideHost()inruntime/instrumentation.ts→runtime/src/sdk-host.ts. Never add@sovereignfs/db/@sovereignfs/mailerback as dependencies of the SDK — thenoExternal-bundle plan is explicitly dropped. Platform internals belong inruntime/src/sdk-host.ts, not inpackages/sdk.- Every package/app extends
packages/tsconfig(base/nextjs/library), established in Task 0.3.2. Easy to forget on new packages. - Manifests are validated at build time. Invalid manifest = failed build.
- Plugin tables are slug-prefixed (
tasks_lists,splitify_groups). Single shared schema, no per-plugin DBs in v1. tenant_ideverywhere on user-scoped tables from day one (future multi-tenancy), even though no multi-tenant logic exists in v1.- DB is dialect-agnostic (Drizzle): SQLite default, Postgres via env only. No SQLite-specific SQL in app code.
- The platform data layer is async (Task 0.5.3). Postgres (node-postgres) has no synchronous query, so
getPlatformDb()and everypackages/dbplatform helper (getPlatformSetting,setAccountPrefs, …) andsdk.platform.getConfig()return promises — alwaysawaitthem. (On SQLite the underlying better-sqlite3 calls still run synchronously; the async signature is the dialect-agnostic contract.) Never reintroduce a synchronous platform-DB read. - Relative SQLite paths resolve against the workspace root (nearest ancestor with
pnpm-workspace.yaml), not the process cwd — all SQLite files land in the single root-leveldata/directory regardless of which app opens them. Implemented in bothpackages/dbandapps/auth/src/db.ts(duplicated deliberately; auth does not depend onpackages/db). - No secrets with defaults.
AUTH_SECRET/SOVEREIGN_AUTH_SECRETetc. must throw on startup if unset. - Plugins compose at their
routePrefixunder ashell-selected route group. The generate script injectsplugins/[id]/app/intoruntime/app/(platform)/(plugins)/<routePrefix>/forshell: defaultplugins (they inherit the platform sidebar via App Router layout nesting — no rewrites). The route segment is the manifestroutePrefix, not the source directory name, soroutePrefixis the single source of truth for a plugin's URL; the(plugins)route group is URL-transparent, soroutePrefix: /consoleserves at/console. The composed segments are copies in dev, symlinks in production (NODE_ENV) — dev must copy because Next's dev route watcher does not follow symlinked route dirs (a symlinked plugin 404s undernext dev); production uses a real symlink instead so a plugin's imports resolve through its ownnode_modulesrather than requiring every dependency it uses to also be declared inruntime/package.json. Composed segments are gitignored by a.gitignoreinside each route group — never edit or commit them. Source of truth is alwaysplugins/[id]/app/. Because production's symlink isn't followed by TypeScript's own module resolution either,runtime/tsconfig.jsonexcludes composed plugin directories ((plugins)and(minimal)) from its type scope — each plugin already typechecks itself in its own repo/CI.shell: overlay(RFC 0001, Task 0.5.10) composes TWICE: the full-page fallback under(plugins)/<routePrefix>/(same as default) and an interception copy under(plugins)/@modal/(.)<routePrefix>/. The@modalparallel-route slot lives inside(plugins)(hosted by a committed(plugins)/layout.tsxthat renders{children}{modal}) so the interceptor and the fallback are folder-siblings in the same route group — interception across the group boundary from(platform)fails at runtime withinitialTree is not iterable. The slot's hand-written@modal/default.tsx(empty fallback) and@modal/layout.tsx(theDialogchrome; renders the Dialog only whenuseSelectedLayoutSegment()is an intercepted segment, never null, so no empty scrim on ordinary pages) are committed; the@modal/(.)*copies are generated and gitignored (the(plugins)/.gitignorekeepslayout.tsx+@modal/{default,layout}.tsx, ignores the rest). OverlayroutePrefixmust be a single segment, and overlay plugins are ineligible as the root plugin (CON-11) —validateRootPluginrejectsshell: 'overlay'. Intra-overlay navigation MUST usereplace, not push. The dialog is dismissed withrouter.back(), which unwinds exactly one history entry; if a plugin's in-dialog tab/section links push (the<Link>default), each one stacks on history and a single dismiss only steps back one tab instead of closing — stale dialog states pile up behind it. Console and Account tab links use<Link replace>; this is documented as a convention for third-party overlay plugins indocs/plugin-development.md. Never reintroduce push-based intra-overlay navigation. Dialog size is plugin-declared via the optional manifestshellConfig.overlaySize(sm|md|lg, defaultlg); the@modal/layout.tsxresolves it from the selected interception segment (overlaySizeForSegmentinruntime/src/overlay.ts). The@sovereignfs/uiDialogrenders fixed-size boxes (each size sets width AND height, content scrolls inside) so the dialog never resizes as its content changes between tabs —lgfills the viewport minus a fixed margin,md/smare fixed and centred; on mobile every size is a full-screen sheet. TheDialogscrim is full-viewport by default but offsets its left edge by the--sv-dialog-inset-leftCSS var (default0); the shell sets it to the sidebar width (--sv-shell-sidebar-width, reset to0on mobile) on.shellso overlay dialogs start at the sidebar's right edge and leave the rail visible/usable — never hardcode the sidebar width into theDialog. shell: minimal(RFC 0014, Task 0.5.25) composes intoruntime/app/(minimal)/— a chrome-free, full-bleed route group (no sidebar, header, or footer). The committed(minimal)/layout.tsxapplies100dvhand safe-area insets; generated composed routes land alongside it (gitignored by(minimal)/.gitignorewhich keepslayout.tsxandminimal.module.css). The session gate still applies — the middleware enforces auth before the plugin renders. Multi-segmentroutePrefixis allowed (unlike overlay, which must be single-segment).minimalplugins ARE eligible as the root plugin (kiosk use case —validateRootPluginacceptsshell: 'minimal'); when used as root there is no platform nav, so the plugin must provide its own navigation back to/launcheror other routes if needed. Never reintroduceprocess.exit(1)for the minimal case ingenerate-registry.ts— it is wired.adminOnlyroutes are gated in the runtime middleware. A request under an admin-only plugin'sroutePrefixfrom a non-platform:adminuser returns 403 (SRS §3.4, PLT-03).- Invite-only is dual-written, and the auth-server copy is authoritative. The Console toggle (CON-10) writes
invite_onlyto both the platform DB (platform_settings, read bysdk.platform.getConfig()) and the auth server's ownauth_settingstable (via the runtime PATCH proxying toapps/auth). Registration enforcement reads only the auth copy — the auth server owns identity and does not read the platform DB. A stored value overrides theAUTH_INVITE_ONLYenv default; absent a stored value, the env default applies. Never make registration read the platform DB instead. root_plugin_idlives inplatform_settings, seeded on first run tofs.sovereign.launcher(PLT-14/PLT-15). The eligible set is installed + enabled + non-adminOnly(validated inruntime/src/root-plugin.ts)./serves the root plugin in place — the middleware rewrites/to the configured plugin'sroutePrefix(URL stays/; the plugin remains reachable at its own prefix too), resolving the prefix at request time viaGET /api/admin/root-plugin(Edge middleware can't read the DB, same fetch pattern as/api/admin/plugins/disabled).(platform)/page.tsxkeeps aredirect()as a fallback for when that resolution fetch fails. Platform tables (tenants,plugin_status,platform_settings) are bootstrapped with dialect-aware CREATE-TABLE-IF-NOT-EXISTS + seed rows inpackages/db'sgetPlatformDb()(packages/db/src/bootstrap.ts,INTEGER/BIGINT+INTEGER/BOOLEANper dialect); the DDL must stay in sync with the Drizzle schemas (schema/sqlite+schema/postgres, guarded by a parity test). drizzle-kit migrations replace this later (0.5.05+).- A row-less plugin (no
plugin_statusrow) defaults to disabled and access-restricted — except automatically in local dev.getDisabledPluginIdsandcanUserOpenPlugin/getRestrictedPluginIds(runtime/src/plugin-status.ts,runtime/src/plugin-access-server.ts) both short-circuit to "fully visible, open to everyone" wheneverbypassPluginVisibilityInDev()is true, which is an equality check onNODE_ENV === 'development'(never!== 'production', since Vitest setsNODE_ENV=testand must keep exercising the real gating logic). This exists because a freshly scaffolded plugin used to stay hidden from its own author until an admin visited Console > Plugins — a DX regression, not intended production behavior. Never applies outsidenext dev; a production or staging deployment still defaults new plugins closed. - Chrome plugins (
fs.sovereign.launcher,fs.sovereign.account,fs.sovereign.console) are reached through the sidebar chrome (home/, Console ⚙, Account avatar), never via the Launcher grid or the sidebar's middle plugin-icon section (LCH-04, PLT-12). The canonical ID set isCHROME_PLUGIN_IDSinruntime/src/launcher-plugins.ts— reuse it, never re-hardcode the list. - Plugins that need the installed-plugin list fetch the gated
/api/plugins(forwarding the session cookie), not import the registry — the SDK boundary rule forbids plugins importingruntime/srcor internal packages. The route is session-gated (middleware injectsx-sovereign-user-role) and role-filters viaselectLauncherPlugins;sdk.dbreplaces this fetch in Task 0.5.5. - The
/api/*namespace is split: reserved runtime segments vs. the public provider namespace (PLT-16). The runtime serves its own first-level segments —account,admin,auth,health,instance,manifest,plugins(runtime/app/api/*) — listed inRESERVED_API_SEGMENTS(runtime/src/api-namespace.ts); a parity test asserts the set matches the on-disk dirs, so a new runtime API route can't silently become delegatable. Every other/api/<slug>/*is the public namespace: the middleware handles it before the session gate (it is unauthenticated — the provider owns auth, e.g. API keys), rewriting to the single registered provider's serve route<routePrefix>/serve/<slug>/<path>, or 404 when no enabled provider is installed. The provider is the one plugin withapiProvider: truein its manifest; exactly one per instance — the generate script fails the build on a second one (findApiProviderin@sovereignfs/manifestis the shared resolver). Never add a newruntime/app/api/*segment without adding it toRESERVED_API_SEGMENTS. - Server-to-server calls to better-auth (
/api/auth/*) must send anOriginheader equal to the auth base URL (SOVEREIGN_AUTH_URL) — better-auth enforces a CSRF origin check and rejects originless POSTs withMISSING_OR_NULL_ORIGIN(403). Applies toupdate-user(Account profile) andchange-password. The session cookie is host-scoped, so forwarding it across the runtime↔auth origins works. - The middleware's unauthenticated
/loginredirect MUST be303, not theNextResponse.redirectdefault of307. 307 preserves the request method, so an unauthenticated POST to a gated route (the logout form once the session has lapsed, any plugin form submit, a server action) redirects asPOST /login— andruntime/app/login/route.tsonly handlesGET, returning 405. 303 (See Other) forces the browser to GET. Any browser-facing redirect to/login(middleware, logout route) must target the public auth URL (SOVEREIGN_AUTH_PUBLIC_URL), never the internalSOVEREIGN_AUTH_URL(auth:3001), which the browser cannot resolve in Docker. 'use client'components must never read browser APIs (navigator,window,localStorage, etc.) inside auseStateinitializer or during render. The server renders without those globals, producing different HTML than the client and triggering a React hydration error. The pattern: always initialise state to a server-safe value (e.g.useState('online')), then read the browser API insideuseEffectand call the setter if needed. The one-frame delay before the UI reflects the real browser state is imperceptible.OfflineBanneris the canonical example — it initialises to'online'and checksnavigator.onLineinuseEffect(runtime/app/(platform)/_components/OfflineBanner.tsx).- Never read
NEXT_PUBLIC_*env vars for a value that must vary per deployment at run time. Next.js inlinesprocess.env.NEXT_PUBLIC_*literals at build time into every bundle (client and server); the Docker images build without an.env, so such a read freezes to its fallback and ignores the env injected at container start. The post-login redirect target (NEXT_PUBLIC_RUNTIME_URL) is therefore resolved server-side at request time viaapps/auth/src/runtime-url.ts(which reads through a computed key so the inliner can't match it) and passed as a prop to the clientLoginForm/RegisterForm. The auth login/registerpage.tsxare server components for this reason..env.exampleleavesNEXT_PUBLIC_RUNTIME_URL/SOVEREIGN_AUTH_URLcommented so the per-environment Compose/code defaults apply (dev → :3000/:3001, prod → :4000/:4001); a hardcoded dev value in.envleaks into the prod stack via interpolation. - better-auth's fresh-session gate is disabled (
session.freshAge: 0inapps/auth/src/auth.ts). By defaultfreshSessionMiddleware(guardingGET /list-sessions, used bysdk.auth.listSessions) returns403 SESSION_NOT_FRESHonce a session is older thanfreshAge(1 day) — so the Account Security tab broke for day-old sessions. Self-hosted users stay signed in for weeks, so freshness re-auth is off. Don't re-enable it without a re-auth flow. (A regression test assertsfreshAge === 0.) - Theme is applied before first paint by an inline script in
runtime/app/layout.tsxreading thesv-themecookie (light/darkapplied directly;system/unset followsprefers-color-scheme). The Account plugin writes the choice toaccount_prefs(authoritative) and mirrors it to thesv-themecookie on PATCH. Avatars live on disk atdata/avatars/<user_id>.<ext>(served by/api/account/avatar/[userId]); the user record'simagefield holds the servable URL. - Security headers are split: static via
next.config.ts, CSP via middleware (RFC 0008 Tier 0, Task 0.5.16). Both apps'next.config.tsemit the static headers (X-Frame-Options: DENY,X-Content-Type-Options: nosniff,Referrer-Policy,Permissions-Policy, and HSTS production-only). The Content-Security-Policy is strict and nonce-based, set per-request in middleware (runtime/middleware.tsextends the session middleware;apps/auth/middleware.tsis dedicated) — every middleware return path must run throughapplyCsp, and the rendered-path request headers carry the nonce so Next nonces its own inline scripts. CSP builders live inruntime/src/security.tsand the duplicatedapps/auth/src/security.ts(the apps share no code). The runtime's pre-paint theme script is a fixed string (runtime/src/theme-script.ts) allowed by a CSP hash (THEME_SCRIPT_CSP_HASH, guarded by a drift test) — not a nonce — so the root layout stays statically renderable (the PWA/offlinefallback needs it). The nonce only applies on dynamically-rendered pages; the runtime's gated pages are dynamic via the(platform)layout'sheaders(), andapps/authforces dynamic (export const dynamicin its root layout). Never add'unsafe-inline'toscript-src. The runtime CSP'sform-actionmust include the browser-facing auth origin, not just'self'. The logout form lives on the runtime origin but its POST 303-redirects to the auth login page on a different origin (SOVEREIGN_AUTH_PUBLIC_URL, e.g.:3001/:4001); browsers checkform-actionagainst the whole redirect chain, so'self'alone silently blocks it (the page just flickers, no navigation). The middleware feeds the parsed auth origin (authPublicOrigin()) tobuildContentSecurityPolicy(authFormActionOrigin). Postgres connects over TLS when the connection string setssslmode(pgSslModeinpackages/db/src/client.ts; CA viaPGSSLROOTCERT). At-rest encryption (Tiers 2–4) is deferred to Task 1.0.01. - A quick-entry input that commits on Enter must also commit on blur, via
useCommitOnEnterOrBlur(@sovereignfs/ui). iOS Safari's native "Previous / Next / Done" keyboard-accessory toolbar — added automatically whenever a Sheet/form has more than one focusable field, with no supported way to suppress it — only ever fires ablurwhen its Done/checkmark is tapped, never a keydown and never a form submit. AnonKeyDown-only Enter handler silently discards whatever was typed the moment a user dismisses the keyboard that way instead of pressing the on-screen Return key. Found and fixed acrosssovereign-tasksandsovereign-shopper's quick-add rows (add task/subtask/list, mobile rename Sheet); seedocs/plugin-development.md's "Committing quick-entry input" for the pattern and its one exception (a field inside a form with its own always-visible submit button — e.g. login, payment — should NOT commit on blur; that's the correct, safer default there). - The
touch-actionCSS property's effective value at any point is the intersection of that element's own value and every ancestor's value, not independently scoped per element. Declaringpan-yon a vertically scrolling child nested inside an ancestor declaringpan-x(e.g. a native horizontal swipe-between-lists carousel) does not "hand off" the horizontal axis to the ancestor — the intersection of{pan-y}and{pan-x}is empty, so neither axis is handled natively anywhere in that subtree, breaking both gestures at once. Fix nested-scroller touch/scroll conflicts with behavioural CSS that doesn't touchtouch-action(e.g.overflow-anchor) or JS-level gesture arbitration, not by declaring narrowertouch-actionvalues on nested perpendicular scroll containers. - The runtime is an installable PWA (
@ducanh2912/next-pwa, PLT-09). The web manifest (runtime/public/manifest.json) and PNG icons (runtime/public/icons/) are committed source; the service worker (sw.js,workbox-*.js,fallback-*.js) is generated intoruntime/public/at build and is gitignored + ignored by ESLint and Prettier — never commit or lint it. The SW is disabled in dev (so it never interferes with HMR), so installability/Lighthouse only apply to a production build (next build). The PWA assets and the/offlinefallback are excluded from the middleware session gate (they must load without a session). - Never switch the service worker's document/page cache entries (
pages,pages-rsc,pages-rsc-prefetch) to a stale-serving strategy (StaleWhileRevalidate/CacheFirst). Sovereign's pages are per-user SSR (nav, plugin list, account state) — replaying a cached rendered shell risks showing a stale or different user's authenticated content after logout/login on a shared device. Keep theseNetworkFirst. The one safe lever for the iOS "white flash on standalone launch" (a real, largely irreducible WebKit launch-image-to-first-paint gap for non-native-wrapped PWAs) is bounding the worst case withnetworkTimeoutSeconds+fallbacks.document— configured inruntime/next.config.ts— not caching the document itself. Seedocs/adhoc/ios-pwa-inspection-findings.md#5. - Production images build from Next.js standalone output (Task 0.5.2). Both
next.config.tssetoutput: 'standalone'andoutputFileTracingRootto the monorepo root — required in a pnpm monorepo or the trace misses workspace package files. The standalone tree mirrors the repo layout, so the runner runsnode runtime/server.js/node apps/auth/server.js(notnext start). The standalone server readsPORT/HOSTNAMEenv (the oldnext start --portflag is gone): the runner setsPORT+HOSTNAME=0.0.0.0(runtime 3000, auth 3001).runtime/.next/staticandruntime/public(which holds the generated PWA assets) must be copied explicitly into the runner — standalone does not include them. Healthchecks use127.0.0.1, notlocalhost(busyboxlocalhost→::1, but the server binds IPv40.0.0.0→ connection refused onlocalhost). The runtime'sHEALTHCHECKhits the public/api/healthliveness route (excluded from the middleware gate); the admin-key-gated/api/admin/healthstays the richer report.docker-compose.prod.ymluses a named volume (sovereign_data), not a host bind mount, for/app/data: the images run non-root, and a named volume inherits the image's/app/dataownership so SQLite/avatar writes work with zero hostchown(a bind mount keeps host ownership and breaks non-root writes on Linux — macOS VirtioFS hides this). Dev (docker-compose.yml) keeps the./databind mount (runs as root). The named volume is pinned with an explicitname: sovereign_dataso Compose doesn't prefix it with the project (checkout-dir) name — the documented backup/restore commands reference it by that exact name. - Both standalone images COPY
pnpm-workspace.yamlinto/app(runtime andapps/authDockerfiles). Next.js standaloneserver.jscallsprocess.chdir(__dirname)at boot, moving cwd to/app/runtime(or/app/apps/auth).findWorkspaceRoot()walks up from cwd and stops at thepnpm-workspace.yamlmarker, returning/app— so relative SQLite paths (sovereign.db,auth.db) and the drizzle migrations folder (packages/db/migrations/, runtime only) resolve against/app, i.e. the mounted/app/datavolume. Without the markerfindWorkspaceRoot()falls back to the post-chdircwd and the DBs land at/app/runtime/data//app/apps/auth/data— OUTSIDE the volume: data does not persist across container recreates and is missing from backups, and the runtime fails to boot (Can't find meta/_journal.json). Never drop thepnpm-workspace.yamlCOPY from either Dockerfile. - The runtime Dockerfile must ship every plugin's
manifest.jsonandmigrations/folder into the runner image, staged into a curated/app/.deploy/pluginsdirectory in the builder stage (not the fullplugins/*/tree — that would drag each plugin'sapp/source andnode_modulesinto the production image for no benefit, since routes are already compiled into the standalone build).runAllPluginMigrations()(runtime/src/plugin-migrations.ts) andbuildIdToDirMap()resolve these paths at server startup relative to the workspace root; if absent, a missing-migrations plugin is silently skipped (existsSyncguard, no error logged) rather than failing loudly — this was the case for every shared/isolated-mode plugin until Sovereign Tasks (bundled with the platform by default) was the first to actually need it, surfacing as a production 500 (relation "..." does not exist) with nothing in the logs pointing at the cause. - A shared or isolated-mode plugin whose application code queries through one dialect's schema (typically
sqlite-core) needs a genuinely separatepgTable-based schema file to generate Postgres migrations from —drizzle-kit generate --dialect postgresqlcannot read asqliteTable()schema; it silently reports zero tables. That Postgres schema file must use plainintegerfor booleans/timestamps, never native Postgresboolean/bigint— Drizzle's query-builder dialect is bound to the client connection, not to the table object's origin, so the existing SQLite-typed query code keeps working against a Postgres-backed client only if the physical columns serialize identically to what the SQLite column mappers already produce. Seedocs/plugin-database.mdfor the full pattern (packages/db/src/schema/{sqlite,postgres}/platform.tsis a different case — the platform's own query code is dialect-aware viapackages/db/src/exec.ts, so its Postgres schema uses native types).