Skip to content

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, including via the @/ alias (runtime/tsconfig.json's "@/*": ["./*"] resolves to the same location — closed as an ESLint gap in Task 3.24, workstream 0012 leg 2, after plugins/console was found using it unflagged). ESLint enforces this (established in Task 0.3.3, verified in Task 0.3.8; the @/-alias gap closed in Task 3.24). Plugins use packages/sdk only. plugins/console is the one documented exception to the runtime/src/@/ restriction specifically — it is a platform-type plugin built and shipped as part of this same monorepo, not a third-party one, and its @sovereignfs/db/manifest/mailer restriction still fully applies (eslint.config.ts gives it its own, narrower rule block, not a blanket exclusion). Anything Console needs from those packages goes behind an admin-key-authenticated runtime/app/api/admin/* route it calls over HTTP (the entitlements/actions.ts pattern) — enqueueBackupJob/listBackupJobs had to move behind such a route for exactly this reason. Do not extend this exception to Launcher, Account, or any other plugin without the same deliberate, documented reasoning.

  • 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 a packages/ui addition 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" in docs/design-system.md.

  • @sovereignfs/sdk is a types-first contract with zero runtime dependencies (RFC 0023, Task 0.5.21). packages/sdk does not import @sovereignfs/db or @sovereignfs/mailer. Implementations are registered by the runtime at startup via provideHost() in runtime/instrumentation.ts → runtime/src/sdk-host.ts. Never add @sovereignfs/db/@sovereignfs/mailer back as dependencies of the SDK — the noExternal-bundle plan is explicitly dropped. Platform internals belong in runtime/src/sdk-host.ts, not in packages/sdk.

  • Adding a new top-level member to SdkHost (packages/sdk/src/host.ts) is a host-implementer-facing breaking change per NFR-04 — bump @sovereignfs/sdk minor, not patch. Same bar as widening an existing field's type (e.g. StorageContext.pluginId, RFC 0044). Not just a semver formality: it breaks pnpm typecheck immediately, not just in theory. Five hand-rolled mock SdkHost objects exist specifically to test the SDK-side wrapper functions in isolation, without a real runtime — packages/sdk/src/__tests__/{events,jobs,mailer-email-plugin-id,sdk,webhooks}.test.ts — each needs a matching stub added for the new member before typecheck passes again, since none of them import the real runtime/src/sdk-host.ts. Found generalizing the sdk.storage/sdk.db.getClient() background-invocation fallback to sdk.env.get(), which needed a new env host member with no prior host indirection at all.

  • 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), and every non-platform plugin gets its own isolated store — a plugin_<slug> Postgres schema or sqld namespace — since epic task 8.28 retired the database.isolation: "shared" manifest option. Only the platform-type plugins (Console, Launcher, Account) write to the platform DB.

  • tenant_id everywhere 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 every packages/db platform helper (getPlatformSetting, setAccountPrefs, …) and sdk.platform.getConfig() return promises — always await them. (SQLite is served by sqld over @libsql/client, which is itself async — no synchronous SQLite path is left in the live server; better-sqlite3 survives only in the one-time legacy-file readers behind sv db migrate-to-postgres and the sqld cutover.) Never reintroduce a synchronous platform-DB read.

  • Relative on-disk paths resolve against the workspace root (nearest ancestor with pnpm-workspace.yaml, via findWorkspaceRoot()), not the process cwd — the drizzle migrations folder (packages/db/migrations/<dialect>), the avatars directory (runtime/src/avatars.ts), auth's legal content, and the legacy .db files that sv db migrate-to-postgres and the sqld cutover read. The live server never opens a SQLite file itself: SQLite is sqld-backed with no plain-file fallback (packages/db/src/client.ts). Implemented in both packages/db and apps/auth (duplicated deliberately; auth does not depend on packages/db).

  • No secrets with defaults. AUTH_SECRET / SOVEREIGN_AUTH_SECRET etc. must throw on startup if unset.

  • Plugins compose at their routePrefix under a shell-selected route group. The generate script injects plugins/[id]/app/ into runtime/app/(platform)/(plugins)/<routePrefix>/ for shell: default plugins (they inherit the platform sidebar via App Router layout nesting — no rewrites). The route segment is the manifest routePrefix, not the source directory name, so routePrefix is the single source of truth for a plugin's URL; the (plugins) route group is URL-transparent, so routePrefix: /console serves 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 under next dev); production uses a real symlink instead so a plugin's imports resolve through its own node_modules rather than requiring every dependency it uses to also be declared in runtime/package.json. Composed segments are gitignored by a .gitignore inside each route group — never edit or commit them. Source of truth is always plugins/[id]/app/. Because production's symlink isn't followed by TypeScript's own module resolution either, runtime/tsconfig.json excludes composed plugin directories ((plugins) and (minimal)) from its type scope — each plugin already typechecks itself in its own repo/CI. The platform plugins in this monorepo have no typecheck script of their own either (cd plugins/console && npx tsc --noEmit silently resolves an unrelated root tsconfig), so pnpm typecheck never compiles a composed plugin's route files — only pnpm --filter runtime build does; a wrong prop name on a @sovereignfs/ui component in Console passed every standalone check and was caught only by a component test. 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 @modal parallel-route slot lives inside (plugins) (hosted by a committed (plugins)/layout.tsx that 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 with initialTree is not iterable. The slot's hand-written @modal/default.tsx (empty fallback) and @modal/layout.tsx (the Dialog chrome; renders the Dialog only when useSelectedLayoutSegment() is an intercepted segment, never null, so no empty scrim on ordinary pages) are committed; the @modal/(.)* copies are generated and gitignored (the (plugins)/.gitignore keeps layout.tsx + @modal/{default,layout}.tsx, ignores the rest). Overlay routePrefix must be a single segment, and overlay plugins are ineligible as the root plugin (CON-11) — validateRootPlugin rejects shell: 'overlay'. Intra-overlay navigation MUST use replace, not push. The dialog is dismissed with router.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 in docs/plugin-development.md. Never reintroduce push-based intra-overlay navigation. Dialog size is plugin-declared via the optional manifest shellConfig.overlaySize (sm | md | lg | auto | fixed, default lg); the @modal/layout.tsx resolves it from the selected interception segment (overlaySizeForSegment in runtime/src/overlay.ts). lg is a fixed-size box (width AND height set, content scrolls inside) — no overlay-shell plugin currently renders into it (Console did, until workstream 0022 moved it to shell: "default"; the PluginAccessDialog/CapabilitiesButton examples below are ordinary in-page Dialog usages inside Console, unaffected by that shell change). fixed is also a true fixed-size box (width AND height both set, content never resizes it) but capped at 64rem × 44rem rather than filling the viewport — the size Account (epic task 14.5) actually renders into: a lg box read as too large for a settings surface (full-viewport, mostly empty for shorter sections like Notifications), but a content-driven size (auto, tried first) had its own real problem — Account's /account route client-side redirects to /account/profile through a near-empty intermediate render, and auto's fit-content visibly shrank the panel to fit that empty page, then grew once the real content landed. fixed's constant footprint has neither failure mode, at the cost of some benign empty space below a short section's content (bounded to 44rem, not the full viewport lg left empty). auto remains available for a plugin whose own footprint genuinely varies a lot with no redirect-shaped gap in its route tree (matching its original design intent — a card detail modal with an optional checklist/comments section) — it sizes to content on both width and height, each capped at min(48rem, 100%), and a plugin adopting it should still give its own root element an explicit width at (or under) that cap and a light min-height floor, since otherwise fit-content measures whatever content currently renders and the panel's width/height visibly jump around between sub-routes; even done well this only bounds, not eliminates, resize-between-routes the way fixed's true fixed box does. sm/md are fixed-width but content-driven height, capped at a per-size max-height (28rem/42rem) beyond which content scrolls internally rather than the panel growing further — changed from also-fixed-height because a short-content consumer (plugins/console/app/plugins/PluginAccessDialog.tsx's own nested "Access" dialog, size="sm") rendered as a mostly-empty box: measured live, its content filled 191px of a 448px fixed panel, and a sibling nested dialog (CapabilitiesButton.tsx, one grantable capability today) filled only 52px of 430px. lg fills the viewport minus a fixed margin, and is centred and capped the same way md/sm are; on mobile every size is a full-screen sheet. The Dialog scrim is full-viewport by default but offsets its left edge by the --sv-dialog-inset-left CSS var (default 0); the shell sets it to the sidebar width (--sv-shell-sidebar-width, reset to 0 on mobile) on .shell so overlay dialogs start at the sidebar's right edge and leave the rail visible/usable — never hardcode the sidebar width into the Dialog.

  • shell: minimal (RFC 0014, Task 0.5.25) composes into runtime/app/(minimal)/ — a chrome-free, full-bleed route group (no sidebar, header, or footer). The committed (minimal)/layout.tsx applies 100dvh and safe-area insets; generated composed routes land alongside it (gitignored by (minimal)/.gitignore which keeps layout.tsx and minimal.module.css). The session gate still applies — the middleware enforces auth before the plugin renders. Multi-segment routePrefix is allowed (unlike overlay, which must be single-segment). minimal plugins ARE eligible as the root plugin (kiosk use case — validateRootPlugin accepts shell: 'minimal'); when used as root there is no platform nav, so the plugin must provide its own navigation back to /launcher or other routes if needed. Never reintroduce process.exit(1) for the minimal case in generate-registry.ts — it is wired.

  • Composed route pruning is recursive to the leaf directory, not top-level-only — a multi-segment routePrefix (shell: minimal explicitly allows this, e.g. /kiosk/display) that gets renamed or uninstalled must have its stale nested directory actually removed from disk, not just orphaned in place with the shared parent segment still matching an active entry. pruneGeneratedEntriesAt/collectComposedLeaves (scripts/generate/compose-routes.ts) walk the full relative path against the active-entry set at every depth, recursing into (and removing-if-empty) any directory that is an ancestor of a currently active target, and removing outright anything that is neither active nor an ancestor of something active. composePlugins() also runs assertNoOrphanedRouteDirectories() after composing/pruning — a generate-time consistency check that walks the composed tree a second, independent way (by presence of a page.tsx/layout.tsx) and fails loudly if anything served by Next.js doesn't correspond to a currently active plugin target. The leaf-detection walk must check the active-entry set before falling back to the page.tsx/layout.tsx heuristic — checking the heuristic first produces false-positive orphan reports on a plugin's own internal Next.js route groups (e.g. (home)), which have no routePrefix mapping of their own but sit above a real page.tsx.

  • shell: default plugins can independently hide the mobile header and/or footer via shellConfig.mobileHeader/shellConfig.mobileFooter (RFC 0075, both boolean, default true) — this is a per-request runtime branch inside (platform)/layout.tsx, not build-time route-group composition (unlike overlay/minimal above). It has to be: the desktop sidebar must render identically regardless of the toggle, and there's no route-group split that varies just the two mobile-only elements without duplicating the sidebar markup across every combination. getMobileChromeConfig() (runtime/src/registry.ts) resolves the per-plugin override; the middleware sets x-sovereign-mobile-header/x-sovereign-mobile-footer (mirroring the offline-route flag below) only when a value deviates from the default; (platform)/layout.tsx omits the <header data-mobile-header> block and/or <MobileNav> server-side (not CSS-hidden) based on those headers. Load-bearing details, easy to regress: (1) shell.module.css's mobile grid (grid-template-rows: auto 1fr auto) relied on implicit DOM-order placement before this — .mobileHeader, .content, and MobileNav.module.css's .footer now each declare an explicit grid-row so omitting a sibling can't renumber the others (a stretched footer over the content row is the failure mode if this is ever removed). (2) --sv-shell-header-height/--sv-shell-footer-height collapse to 0px via .shell[data-mobile-header-hidden]/[data-mobile-footer-hidden] attribute selectors (higher specificity than the base .shell rule, same element) — every downstream consumer (.content's footer-clearance padding, --sv-dialog-inset-top, MobileSearch's top/bottom clearance, NotificationBell's popover offset) inherits the fix through the CSS custom-property cascade; don't re-derive per-consumer overrides. (3) ClientShell's offline-route refresh-diffing (see below) is generalized — not duplicated — to also force router.refresh() when a client-side navigation crosses a mobile-chrome visibility boundary, using the shared mobileHeaderVisible/mobileFooterVisible helpers in runtime/src/mobile-chrome.ts; without it a soft nav into/out of a reduced-chrome plugin would keep rendering the previous route's header/ footer state. That same effect also directly sets/clears the --sv-dialog-inset-top inline style on transition, since syncViewport()'s [data-mobile-header] measurement only runs on mount and specific resize/visibility events — never on a plain pathname change — so it can't be relied on to correct a stale inline value across navigations.

  • adminOnly routes are gated in the runtime middleware. A request under an admin-only plugin's routePrefix from a user without the console:access capability (RFC 0021 — owner, admin, and auditor hold it) returns 403 (SRS §3.4, PLT-03).

  • Every server action authorizes inside the action — the middleware's path-based gate is not a substitute. A 'use server' function is a public POST endpoint dispatched by action id, not by the page it was written on, so the adminOnly check the middleware applies to /console cannot be assumed to cover it. Every action begins with const session = await sdk.auth.requireSession(); and an explicit sdk.auth.hasCapability(session, '<capability>') check — requireSession() alone only proves the caller is some logged-in user, which is not an admin check. This binds hardest in plugins/console/app/**/actions.ts, whose adminFetch helper attaches SOVEREIGN_ADMIN_KEY: an action reachable without a capability check hands the platform's own admin credentials to whoever calls it. Precedent: plugins/console/app/plugins/remove-actions.ts shipped with no check at all while every sibling action file had one (found by CodeQL alert #1, which flagged the execSync in it and not the missing authorization).

  • Never interpolate a request-derived value into a shell command string. Use execFileSync/execFile with an argv array so no shell is spawned. JSON.stringify() is not shell quoting — it escapes " and \ but leaves $(…), backticks and ${…} live inside the double quotes it adds, and the shell expands all three. Where the value identifies a known entity, resolve it against a server-side allowlist (e.g. the installed-plugin registry) and pass the resolved string, so the subprocess argument never originates from the request at all.

  • An anchored quantifier immediately before $/^ is quadratic even alone, with no g flag and no alternation. /-+$/ against a long run of - that does not end in one (e.g. '-'.repeat(n) + 'X') backtracks the whole run one character at a time trying to satisfy $, fails, then the search restarts that same backtrack from the next starting offset — O(n²) from a single anchored +. Dropping g or splitting /^-+|-+$/g into two separate replaces (a real first attempt, corrected in the same PR that introduced it — CodeQL alert #5/#8) removes the multiplicative effect of global re-matching but not this. The actual fix is to not use a backtracking regex for the trim at all: a manual index scan (while (s[start] === '-') start++, mirrored from the end) is O(n) with nothing to exploit. See slugify() in runtime/app/api/admin/groups/route.ts and slugifyVitePressHeading() in scripts/check-doc-links.ts.

  • Invite-only is dual-written, and the auth-server copy is authoritative. The Console toggle (CON-10) writes invite_only to both the platform DB (platform_settings, read by sdk.platform.getConfig()) and the auth server's own auth_settings table (via the runtime PATCH proxying to apps/auth). Registration enforcement reads only the auth copy — the auth server owns identity and does not read the platform DB. A stored value overrides the AUTH_INVITE_ONLY env default; absent a stored value, the env default applies. Never make registration read the platform DB instead.

  • root_plugin_id lives in platform_settings, seeded on first run to fs.sovereign.launcher (PLT-14/PLT-15). The eligible set is installed + enabled + non-adminOnly (validated in runtime/src/root-plugin.ts). /serves the root plugin in place — the middleware rewrites / to the configured plugin's routePrefix (URL stays /; the plugin remains reachable at its own prefix too), resolving the prefix at request time via GET /api/admin/root-plugin (Edge middleware can't read the DB, same fetch pattern as /api/admin/plugins/disabled). (platform)/page.tsx keeps a redirect() 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 in packages/db's getPlatformDb() (packages/db/src/bootstrap.ts, INTEGER/BIGINT + INTEGER/BOOLEAN per dialect); the DDL must stay in sync with the Drizzle schemas (schema/sqlite + schema/postgres, guarded by a parity test). drizzle-kit migrations (packages/db/migrations/<dialect>, runMigrations()) are the primary schema mechanism, but this bootstrap DDL is still what creates these tables wherever migrations haven't run first — see the bootstrap.ts bullet below; a new column needs both.

  • A row-less plugin (no plugin_status row) defaults to disabled and access-restricted — except automatically in local dev. getDisabledPluginIds and canUserOpenPlugin/getRestrictedPluginIds (runtime/src/plugin-status.ts, runtime/src/plugin-access-server.ts) both short-circuit to "fully visible, open to everyone" whenever bypassPluginVisibilityInDev() is true, which is an equality check on NODE_ENV === 'development' (never !== 'production', since Vitest sets NODE_ENV=test and 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 outside next dev; a production or staging deployment still defaults new plugins closed.

  • A manifest disabled: true plugin is hard-disabled unconditionally, including in local dev. Unlike the row-less-defaults-to-open dev bypass above, getDisabledPluginIds() (runtime/src/plugin-status.ts) computes manifest hard-disabled ids (getHardDisabledPluginIds(), runtime/src/registry.ts) before checking bypassPluginVisibilityInDev() and returns them regardless. This is deliberate: a hard disable is the plugin author's own declaration shipped with the code, not a missing-row default a local session should see through. It also has no DB override — activatePlugin() (runtime/src/plugin-catalog.ts) refuses to create a plugin_status row for one at all, and runAllPluginMigrations() (runtime/src/plugin-migrations.ts) skips it entirely.

  • Chrome plugins (fs.sovereign.launcher, fs.sovereign.account, fs.sovereign.console, fs.sovereign.inbox) are reached through the sidebar chrome (home /, Console ⚙, Account avatar, Inbox 🔔 bell), never via the Launcher grid or the sidebar's middle plugin-icon section (LCH-04, PLT-12). The canonical ID set is CHROME_PLUGIN_IDS in runtime/src/launcher-plugins.ts — reuse it, never re-hardcode the list. Always enabled, never toggleable, by construction — PATCH /api/admin/plugins/[id] refuses to flip a chrome plugin's plugin_status row in either direction, and getPluginCatalog() excludes them from Console's Apps list entirely, so there is no admin UI path to a chrome plugin at all. checkBootCompatibility() (runtime/src/boot-compat.ts) must honor this too — it used to disable any incompatible plugin unconditionally, which could silently 404 a chrome plugin platform-wide with no way back (fixed: chrome plugins are now exempted from that write, though still marked incompatible for health/admin visibility). If a chrome plugin's row ever ends up enabled: false regardless (a manual DB edit, a future bug), sv plugin status <id> / sv plugin enable <id> (bin/sv.ts) are the only recovery path — a direct, unguarded DB write, deliberately outside the admin API's refusal.

  • 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 importing runtime/src or internal packages. The route is session-gated (middleware injects x-sovereign-user-role) and role-filters via selectLauncherPlugins. Launcher, Account's portability panel, and Console's plugin actions all use this route; sdk.db is for a plugin's own data, never the registry.

  • 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 in RESERVED_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 with apiProvider: true in its manifest; exactly one per instance — the generate script fails the build on a second one (findApiProvider in @sovereignfs/manifest is the shared resolver). Never add a new runtime/app/api/* segment without adding it to RESERVED_API_SEGMENTS.

  • Server-to-server calls to better-auth (/api/auth/*) must send an Origin header equal to the auth base URL (SOVEREIGN_AUTH_URL) — better-auth enforces a CSRF origin check and rejects originless POSTs with MISSING_OR_NULL_ORIGIN (403). Applies to update-user (Account profile) and change-password. The session cookie is host-scoped, so forwarding it across the runtime↔auth origins works.

  • The middleware's unauthenticated /login redirect MUST be 303, not the NextResponse.redirect default of 307. 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 as POST /login — and runtime/app/login/page.tsx is a page route that only serves GET, so the browser gets 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 internal SOVEREIGN_AUTH_URL (auth:3001), which the browser cannot resolve in Docker.

  • 'use client' components must never read browser APIs (navigator, window, localStorage, etc.) inside a useState initializer 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 inside useEffect and call the setter if needed. The one-frame delay before the UI reflects the real browser state is imperceptible. OfflineBanner is the canonical example — it initialises to 'online' and checks navigator.onLine in useEffect (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 inlines process.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 via apps/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 client LoginForm/ RegisterForm. The auth login/register page.tsx are server components for this reason. .env.example leaves NEXT_PUBLIC_RUNTIME_URL / SOVEREIGN_AUTH_URL commented so the per-environment Compose/code defaults apply (dev → :3000/:3001, prod → :4000/:4001); a hardcoded dev value in .env leaks into the prod stack via interpolation.

  • better-auth's fresh-session gate is disabled (session.freshAge: 0 in apps/auth/src/auth.ts). By default freshSessionMiddleware (guarding GET /list-sessions, used by sdk.auth.listSessions) returns 403 SESSION_NOT_FRESH once a session is older than freshAge (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 asserts freshAge === 0.)

  • Profile self-mutations must clear both session_data cookie variants (better-auth.session_data and __Secure-better-auth.session_data, each with maxAge: 0) — better-auth's signed session cache lives for 300s, so a self-edit that only clears one variant (or neither) leaves the chrome showing the stale name/avatar for up to 5 minutes even though the underlying record already changed. Done in runtime/app/api/account/avatar/route.ts and plugins/account/app/actions.ts.

  • Theme is applied before first paint by an inline script in runtime/app/layout.tsx reading the sv-theme cookie (light/dark applied directly; system/unset follows prefers-color-scheme). The Account plugin writes the choice to account_prefs (authoritative) and mirrors it to the sv-theme cookie on PATCH. Avatars live on disk at data/avatars/<user_id>.<ext> (served by /api/account/avatar/[userId]); the user record's image field 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.ts emit 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/proxy.ts extends the session middleware; apps/auth/proxy.ts is dedicated) — every middleware return path must run through applyCsp, and the rendered-path request headers carry the nonce so Next nonces its own inline scripts. CSP builders live in runtime/src/security.ts and the duplicated apps/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 /offline fallback needs it). The nonce only applies on dynamically-rendered pages; the runtime's gated pages are dynamic via the (platform) layout's headers(), and apps/auth forces dynamic (export const dynamic in its root layout). Never add 'unsafe-inline' to script-src. The runtime CSP's form-action must 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 check form-action against the whole redirect chain, so 'self' alone silently blocks it (the page just flickers, no navigation). The middleware feeds the parsed auth origin (authPublicOrigin()) to buildContentSecurityPolicy (authFormActionOrigin). Postgres connects over TLS when the connection string sets sslmode (pgSslMode in packages/db/src/client.ts; CA via PGSSLROOTCERT). Application-level at-rest encryption (RFC 0071, SQLite-only) shipped and was later retired after repeated hardening rounds (docs/incidents/2026-07-24-rfc-0071-encryption-rollout.md); no dialect has one today.

  • 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 a blur when its Done/checkmark is tapped, never a keydown and never a form submit. An onKeyDown-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 across sovereign-tasks and sovereign-shopper's quick-add rows (add task/subtask/list, mobile rename Sheet); see docs/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-action CSS 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. Declaring pan-y on a vertically scrolling child nested inside an ancestor declaring pan-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 touch touch-action (e.g. overflow-anchor) or JS-level gesture arbitration, not by declaring narrower touch-action values on nested perpendicular scroll containers. @sovereignfs/ui's SwipableMobileCarousel/SwipableMobileCarouselSlideBody already account for this (see that component's CSS) — a new vertically-scrolling slide body should not need to touch touch-action at all.

  • The runtime is an installable PWA (PLT-09). The web manifest (runtime/public/manifest.json) and PNG icons (runtime/public/icons/) are committed source; the service worker (runtime/worker/index.ts + routes.ts, on Serwist) is bundled into runtime/public/sw.js by runtime/scripts/build-sw.ts after next build — one self-contained script, gitignored + ignored by ESLint and Prettier; never commit or lint it. The build step is bundler-agnostic (it globs .next/static for the precache manifest), which is what let the runtime drop the webpack-only @ducanh2912/next-pwa and build with Turbopack under Next 16. The SW is disabled in dev (nothing builds it, and app/_components/ServiceWorkerRegistration.tsx unregisters a stale one so it never interferes with HMR), so installability/Lighthouse only apply to a production build (pnpm --filter @sovereignfs/runtime build). The PWA assets and the /offline fallback are excluded from the middleware session gate (they must load without a session). A plugin declaring installable: true (RFC 0081) gets its own manifest at /api/manifest/[pluginId], nested under the already-reserved, already session-exempt manifest API segment — safe for the same reason: it re-exposes only fields the plugin's own manifest already publishes (name, description, icon, routePrefix), all of which any authenticated user already sees on that plugin's sidebar icon or Launcher tile. No per-user data, no DB write. The manifest[].icons array's own URLs need the identical exemption, on a different matcher entry — runtime/proxy.ts's matcher must exclude plugin-icons/ alongside icons/, or a manifest icon fetch 303-redirects instead of returning an image. This is not merely inconsistent; most browsers don't follow a redirect when fetching a manifest icon for an installability check, so a gated icon can silently fail the entire install prompt with no other symptom — confirmed live (curl against a running instance) before the matcher entry was added, task 2.26. runtime/src/plugin-manifest.ts's buildPluginManifestIcons() and scripts/generate-registry.ts's copyPluginIcons() are the two halves of this: the former lists an icon URL only if the latter is guaranteed to have written that exact file (an icon fallback, or a matching author-supplied icons.* path) — never a guess.

  • A generated maskable plugin icon (RFC 0081, scripts/generate-registry.ts) is always composited onto an opaque background plate, never left transparent. A transparent maskable icon renders as a floating glyph on a platform-chosen background and looks broken on Android. The plate color is the same #09090b both manifest routes (instance-level and per-plugin) already hardcode as theme_color/background_color — there is no per-instance background_color config field anywhere in the platform today (only instancePrimary/instanceRadius do), and these icons are static, build-time PNGs baked into one Docker image shared across every deployment, so they couldn't vary per-instance even if that field existed.

  • A cached authenticated document must never be served to a different user. Sovereign's pages are per-user SSR (nav, plugin list, account state), so replaying a cached rendered shell for the wrong account — after logout/login on a shared device, or on a device several people use — discloses one user's content to another. This is the guarantee; the mechanism below is how it is currently met, and may change as long as the guarantee does not.

    How it is met today: the gated-page route in runtime/worker/routes.ts (isGatedPageNavigation: every same-origin navigation that isn't an API route or a declared offline shell — Console, Account, any plugin without an offline tier) is handled by Serwist's NetworkOnly, a strategy with no cache to read or write, bounded by networkTimeoutSeconds; on any failure the fallback plugin serves the precached /offline page. There is no cached document to ever replay to the wrong user, because none exists. (Under next-pwa this was a NetworkFirst whose cacheWillUpdate returned null, because workbox-build's config schema rejected networkTimeoutSeconds on NetworkOnly; the route table is real bundled code now, so the strategy can say what it means.) Manifest-declared offline routes (offline: 'offline-first' | 'device-only', research 0012) are the one exception, cached via the separate offline-shells entry — safe because that document is required to be a user-neutral shell (enforced by runtime/src/__tests__/offline-route-neutrality.test.ts: no per-user SSR content at all, personalization hydrates client-side), not because of any per-request identity check. / shares this treatment whenever Launcher (the default root) is itself offline-first — see runtime/src/registry.ts's getOfflineRoutePrefixes().

    A signed, per-user offline session assertion previously partitioned the pages cache instead (research 0012, epic tasks 1.21/2.31/2.32) — every entry keyed by a signature-verified user id, so a request whose user couldn't be established got an anonymous key rather than colliding with a real one. Removed after live testing (sign a user out, take the device offline, reload) found two compounding problems: the client-side half that populated the assertion was never actually wired into the app, so the partition key was always the anonymous fallback in practice; and separately, next-pwa's own default caching of bare / (the "start-url" cache) had no partitioning or session check of any kind and was never routed through this mechanism at all, so a signed-out user's last-cached / replayed indefinitely regardless. Both findings point the same direction: a never-exercised, per-request signature-verification path is a much larger attack surface to keep correct than simply not caching what cannot be proven safe to replay. If per-user offline access to non-neutral pages is ever revisited, budget for the same live, adversarial verification this fix relied on — the failure here shipped past pnpm test and code review and was only caught by actually signing out and going offline against a real build.

    This rule previously read "never switch these entries to a stale-serving strategy, keep them NetworkFirst." That was rewritten once (research 0012) to state the requirement rather than the mechanism, on the premise that offline-first needs to serve some cached document with no network. That premise still holds for manifest-declared offline routes (met via neutrality, above) but not for arbitrary per-user pages — pages is back to never stale-serving, which happens to again read like the original mechanism, this time for a verified rather than assumed reason.

    Separately, the 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) remains bounding the worst case with networkTimeoutSeconds + fallbacks.document, not loosening the above. See docs/research/0011-ios-pwa-inspection-findings.md #5.

  • Production images build from Next.js standalone output (Task 0.5.2). Both next.config.ts set output: 'standalone' andoutputFileTracingRoot to 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 runs node runtime/server.js / node apps/auth/server.js (not next start). The standalone server reads PORT/HOSTNAME env (the old next start --port flag is gone): the runner sets PORT + HOSTNAME=0.0.0.0 (runtime 3000, auth 3001). runtime/.next/static and runtime/public (which holds the generated PWA assets) must be copied explicitly into the runner — standalone does not include them. Healthchecks use 127.0.0.1, not localhost (busybox localhost→::1, but the server binds IPv4 0.0.0.0 → connection refused on localhost). The runtime's HEALTHCHECK hits the public /api/health liveness route (excluded from the middleware gate); the admin-key-gated /api/admin/health stays the richer report. docker-compose.prod.yml uses 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/data ownership so avatar and other on-disk writes work with zero host chown; database data itself lives in the sqld service's own sovereign_sqld_data volume (a bind mount keeps host ownership and breaks non-root writes on Linux — macOS VirtioFS hides this). Dev (docker-compose.yml) keeps the ./data bind mount (runs as root). The named volume is pinned with an explicit name: sovereign_data so 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.yaml into /app (runtime and apps/auth Dockerfiles). Next.js standalone server.js calls process.chdir(__dirname) at boot, moving cwd to /app/runtime (or /app/apps/auth). findWorkspaceRoot() walks up from cwd and stops at the pnpm-workspace.yaml marker, returning /app — so the drizzle migrations folder (packages/db/migrations/, runtime only), the avatars directory, and auth's legal content resolve against /app, i.e. the mounted /app/data volume. Without the marker findWorkspaceRoot() falls back to the post-chdir cwd: on-disk writes land at /app/runtime/data / /app/apps/auth/data — OUTSIDE the volume, lost on container recreate and missing from backups — and the runtime fails to boot (Can't find meta/_journal.json). (Before the sqld cutover this also put the SQLite database files themselves outside the volume; database data now lives in the sqld service's sovereign_sqld_data volume.) Never drop the pnpm-workspace.yaml COPY from either Dockerfile.

  • The runtime Dockerfile must ship every plugin's manifest.json and migrations/ folder into the runner image, staged into a curated /app/.deploy/plugins directory in the builder stage (not the full plugins/*/ tree — that would drag each plugin's app/ source and node_modules into the production image for no benefit, since routes are already compiled into the standalone build). runAllPluginMigrations() (runtime/src/plugin-migrations.ts) and buildIdToDirMap() resolve these paths at server startup relative to the workspace root; if absent, a missing-migrations plugin is silently skipped (existsSync guard, 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 separate pgTable-based schema file to generate Postgres migrations from — drizzle-kit generate --dialect postgresql cannot read a sqliteTable() schema; it silently reports zero tables. That Postgres schema file must use plain integer for booleans/timestamps, never native Postgres boolean/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. See docs/plugin-database.md for the full pattern (packages/db/src/schema/{sqlite,postgres}/platform.ts is a different case — the platform's own query code is dialect-aware via packages/db/src/exec.ts, so its Postgres schema uses native types).

  • sdk.device.getSurface() / x-sovereign-surface (RFC 0080), and x-sovereign-focus-plugin (RFC 0082), are presentation hints only, never a security boundary. Both derive from the shell's own User-Agent — a client-controlled value any caller can set to anything — so neither must ever be an input to authorization, entitlement, paywall, or data-access decisions. Anything that must not be reachable is gated by session, capability, or plugin permission, never by surface or focus. RFC 0082's route lock (runtime/src/route-lock.ts's decideFocusRoute(), wired into runtime/proxy.ts) is a UX and product-scoping mechanism on top of the focus signal, not a security boundary: an out-of-focus path redirects to the focused plugin's root rather than being denied, and a forged focus target (or an edited User-Agent) reaches exactly the routes the caller's session/capability/plugin-permission gates already allow — those gates run entirely independently of the lock, before and after it. runtime/proxy.ts strips any inbound x-sovereign-surface / x-sovereign-shell-version / x-sovereign-focus-plugin header before injecting its own (all parsed from the shell's single Sovereign-Shell/<mobile|desktop>-<platform> <version> (focus=<pluginId>) User-Agent token via runtime/src/surface.ts — RFC 0082 deliberately extends RFC 0080's existing token rather than inventing a second one), the same discipline already applied to the x-sovereign-user-* family — but the stripping only protects against a spoofed header; it does nothing about a spoofed User-Agent, which is the actual trust boundary this rule exists to name.

  • apps/auth reads the exact same DB_DIALECT/POSTGRES_DB_URL the platform does — no separate auth-specific database variable. This replaced an earlier design where auth resolved its own dialect independently from a separate AUTH_DATABASE_URL, which was a real production gap: an instance whose platform core had already been migrated to Postgres (DB_DIALECT=postgres) could leave AUTH_DATABASE_URL unset, silently stranding auth on its SQLite default while everything else moved, invisible because nothing compared the two. Removing the second variable removes the disagreement entirely, rather than detecting it after the fact. Auth still gets its own dedicated store — a Postgres schema (sovereign_auth, pinned via the connection's search_path startup option, same technique isolated plugins use) or an sqld namespace of the same name — so better-auth's tables can never collide with the platform's own. apps/auth still deliberately doesn't import @sovereignfs/db (service-boundary independence: own Dockerfile, own deploy) — it duplicates the small amount of dialect/sqld resolution logic it needs (apps/auth/src/db.ts) rather than sharing code, but reads the same env var names the platform does.

  • Every isolated-mode Postgres plugin needs its own migrationsTable (pluginMigrationsTableName(id)), passed explicitly to runPluginMigrations() — the untouched default collides across plugins. drizzle-orm's node-postgres migrator tracks applied migrations in a table that lives in a fixed drizzle schema, regardless of the connecting pool's search_path — so two isolated plugins left on the default table name (__drizzle_migrations) silently share ONE tracking table between them, even though their actual data tables correctly land in separate plugin_<slug> schemas via search_path. The second plugin's migrator compares its own migration timestamps against whatever the first plugin's migrations left as the newest row in that shared table and concludes "already applied" — skipping every CREATE TABLE statement with no error, no warning: the schema exists, empty, indistinguishable from a successful no-op migration. pluginMigrationsTableName() already existed to prevent this exact hazard for shared-mode plugins (writing into the platform DB) — it was never extended to isolated-mode Postgres because, until a real production migration (workstream: legacy SQLite → Postgres, task 8.25's follow-up) added a second one, only one isolated Postgres plugin (com.mooniak.tritext) had ever existed, so the collision was latent, not yet possible. Never apply this to isolated SQLite plugins — a genuinely separate file per plugin has no collision risk, and every existing SQLite-isolated plugin already has real migration history under the untouched default name; passing a different table name there would orphan it, not fix anything. Scope the fix by pluginDb.dialect === 'postgres', exactly as runtime/src/plugin-migrations.ts and both bin/sv.ts isolated-Postgres call sites do.

  • Trust between two first-party services on the internal Docker network reuses apps/relay/src/enrollment.ts's signed-token pattern — do not design a new mechanism per service pair. A self-verifying, HMAC-SHA256 header.payload.signature token (hand-rolled with node:crypto, no library), verified with timingSafeEqual, no persistent state, revocation via secret rotation. First applied beyond apps/relay itself in apps/harness/src/enrollment.ts (RFC 0063, workstream 0014 leg 2) — apps/harness and Warden's server-side code authenticate each other this way, SOVEREIGN_HARNESS_ENROLLMENT_SECRET in place of RELAY_ENROLLMENT_SECRET. The scenario doesn't have to match relay's exactly (many remote instances enrolling with one shared service) for the pattern to still apply — apps/harness has exactly one caller on a private network, and reuses the same shape anyway, since it costs nothing extra and any later genuinely-remote consumer needs no redesign.

  • A Compose service needing a file to exist before its own process starts (no wait/retry of its own) needs an explicit wait-loop entrypoint override — depends_on only orders container start, not readiness for an arbitrary precondition like a downloaded file landing on a shared volume. docker-compose.yml's harness-engine service (llama.cpp server) is the example: -m <path> is a required startup arg with no built-in wait behavior, so its entrypoint polls until [ -s /models/model.gguf ] before exec-ing the real binary. Verify the image's actual entrypoint binary path first (docker inspect <image> --format '{{.Config.Entrypoint}}') before writing the override — guessing it wrong silently breaks the service with no useful error.

  • A self-hosted service's first-boot asset download (a model file, a large bundle) should be lazy and non-blocking, not a gate on container startup or the Docker healthcheck. apps/harness/src/model.ts starts the Next.js server immediately and downloads the GGUF model in the background; /api/health exposes modelStatus separately so a caller can distinguish "engine unreachable" from "engine up, model still downloading" from "ready" (RFC 0063 §6's failure-mode table treats this as a normal, observable state, not an edge case to hide behind orchestration timing). Download completion is signaled by an atomic rename to a fixed final filename — any reader (including a separate sidecar container polling for the file) only ever observes either nothing or a fully-verified file, never a partial download.

  • A plugin's page padding and max-width come from @sovereignfs/ui's PageContainer, never from local padding/max-width in app/layout.tsx or a page-level CSS module (task 9.25). The runtime shell (runtime/app/(platform)/shell.module.css) applies no gutter of its own to plugin content — only clearance for chrome it alone can measure (the offline banner's reserved height while visible, the mobile footer's height). A plugin that skips PageContainer renders edge-to-edge; a plugin that pads itself and wraps in PageContainer double-pads (the exact bug this rule exists to prevent — every plugin under example-plugins/ was double-padded until task 9.25 fixed both the plugins and the two scaffolders, bin/helpers.ts and packages/create-plugin/src/index.ts, that had been generating the anti-pattern into every new plugin). The one case that needs no manual handling: an overlay-shell (shell: "overlay") plugin renders the same page tree into both Dialog (soft navigation) and the shell's full-page fallback (hard navigation) — Dialog's content region sets --sv-page-gutter: 0, which PageContainer reads automatically to stand its own padding down, so the tree is padded correctly in both hosts with no per-context branching in plugin code. Plugins that opt out of the shell entirely via data-plugin-fullbleed (sovereign-tasks, sovereign-shopper) manage their own layout and don't use PageContainer at all. Full detail: docs/design-system.md's "Page layout" section.

  • A position: sticky element with no compositing layer of its own can go stale/blank for a stretch of frames during a fast scroll on iOS Safari, instead of repainting at its stuck position — a WebKit momentum-scroll re-tiling quirk, not a stacking-order or z-index bug (overflow: hidden alone does not fix it: that clips the element's own painted content but doesn't stop a stale raster tile from the scrolling ancestor showing through underneath it for a frame). Fix: transform: translateZ(0) on the sticky element, promoting it to its own compositing layer rasterized independently of the scroller's tile grid. This is a generic CSS gotcha, not specific to any one plugin — apply it to any position: sticky element that lives inside a container real users will fast-scroll on a touch device (a sticky list/section header, a sticky bottom action bar, a sticky detail-pane header). Skip it on position: sticky elements that are desktop/pointer-only (e.g. the runtime shell's own desktop sidebar, shell.module.css's .sidebar) — the bug is a touch-momentum artifact, and an unnecessary compositing layer has its own cost (a new stacking context, extra GPU memory). Not reliably reproducible in this repo's own Chromium-based browser-preview tooling — confirming it requires a real iOS Safari session (device or Simulator) with a genuinely fast flick, not a scripted/synthetic gesture; a fix's correctness can only be verified by confirming the CSS rule itself is live (getComputedStyle resolves the transform), not by reproducing the underlying flash in this tooling. Found live (sovereign-tasks, via ffmpeg frame-by-frame analysis of a user-supplied screen recording, after several rounds of synthetic-gesture reproduction attempts failed) across four separate elements — a task row's swipe-actions container, a sticky list header, a sticky task-detail header, and a sticky bulk-action bar — then applied proactively at the platform level to every other position: sticky element found to be at real risk (mobile, inside scrolling content): @sovereignfs/ui's MobileHeader and SystemBanner, and the account/console platform plugins' own mobile tab-strip headers. Since plugins outside this monorepo are not something this codebase controls or can audit, the two shared-chrome cases (MobileHeader, SystemBanner) matter most — fixing them once here protects every plugin that renders them, present and future, without requiring any plugin author to know this rule exists.

  • A self-rendered MobileHeader/MobileFooter (@sovereignfs/ui, the shellConfig.mobileHeader/mobileFooter: false pattern, RFC 0075/0088) publishes its own measured height as --sv-shell-header-height/ --sv-shell-footer-height on #sv-app-shell automatically — both components call usePublishShellChromeHeight internally (packages/ui/src/hooks/usePublishShellChromeHeight.ts) and need no wiring from the consumer. Before this existed, only the platform's own header/footer had its height accounted for — a plugin opting out to render this chrome itself (a sanctioned pattern, not a workaround) had no way to tell the shell what its real height was, and silently inherited the shell's own 0px override (shell.module.css's data-mobile-header-hidden/data-mobile-footer-hidden, which assumes "no chrome at all," not "chrome the shell doesn't know the size of"). Sheet, Drawer, and Dialog all size themselves against these two variables to stop above/below the chrome instead of sliding underneath it; with the variable stuck at 0px, they extended full-height, and since the header/footer's own z-index (101) beats an overlay's (100), the chrome visibly covered the overlay's last ~60px. Reported live as "Drawer has broken" / "task edit screen content not scrollable" (sovereign-tasks — the Delete button and Apps-drawer bottom row were unreachable, not actually a scroll bug) before being root-caused and fixed once here so every current and future consumer of these two components gets it for free. If you touch either component or these three overlays, preserve the contract: the measured value must always win over the shell's own 0px collapse, and must be removed (not just left stale) on unmount so navigating away restores the shell's own cascade. Do not hand-roll this measurement in a plugin again — if MobileHeader/MobileFooter ever stop covering a real layout, extend the shared hook, not the plugin.

  • Prefer a synchronous getBoundingClientRect() read (in useLayoutEffect, re-run on a window resize listener) over ResizeObserver for measuring an already-rendered element's size. In live testing, a freshly created ResizeObserver never fired its callback even once for an already-mounted, stably-sized, non-zero element — reproduced in both this repo's own Chromium-based browser-preview tooling and a real WebKit iOS Simulator session, cause not fully root-caused. This is not a universal claim that ResizeObserver is broken — only that it has been observed to silently fail in exactly this scenario in this codebase's own tooling, and a getBoundingClientRect()-based measurement worked immediately and reliably as a drop-in replacement both times it was tried. If a ResizeObserver-based measurement isn't firing and the element is provably rendered with a stable, non-zero size, don't spend time adding more ResizeObserver debugging — switch to this pattern first. usePublishShellChromeHeight (above) uses it.

  • A signed-download route's token lives in its own [token] path segment, never in params without a matching folder and never read off params at all if the route was only ever given a [jobId]-shaped directory. Found in runtime/app/api/backup-jobs/[jobId]/download/route.ts (RFC 0084, epic task 8.16): the handler destructured token from params, but the route's own directory only had a [jobId] segment — no [token] folder existed, so token was always undefined and every request 404'd unconditionally, even with a correctly-signed token, from the moment the route shipped. Fixed by moving the file to [jobId]/download/[token]/route.ts, matching the existing, working precedent this route was already supposed to mirror: runtime/app/api/storage/[token]/route.ts (RFC 0044). When adding a new signed-URL download route, copy that file's shape — dynamic segment per path element the handler reads from params, verified by checking the actual app/api/** directory tree, not just the doc comment describing it. A second, distinct bug was later found in this same route (epic task 8.17 live verification): the route's own doc comment claimed a runtime/proxy.ts matcher exemption "by design" — modeling itself on api/storage's already-correct exemption — but api/backup-jobs was never actually added to the matcher's negative-lookahead list. A request carrying a genuinely valid, unexpired signed token still 303-redirected to /login before the route's own token check ever ran, because middleware intercepted it first; reproduced live via curl with no session cookie against a real completed backup job, and confirmed fixed the same way against a real Postgres-backed job (200, correct bytes, decrypts with the real passphrase). The lesson generalizes: a route's own doc comment claiming a middleware exemption is not proof it was actually applied — check the real matcher array in runtime/proxy.ts directly before trusting it. Fixed by adding api/backup-jobs to that array — safe here specifically because, like api/storage, no other route shares this prefix (unlike api/instance above, which is deliberately kept inside the matcher because other, privileged routes share its prefix and must stay session-gated). This affected both epic 8.18's existing Account download flow and epic 8.17's new Console one, since both surface the exact same download URL. A new regression test exercises config.matcher directly (runtime/src/__tests__/middleware-regression.test.ts's middleware matcher describe block) rather than relying on a doc comment — confirmed to fail against the pre-fix matcher before being trusted.

  • The production runner image runs instance backup/restore through a bundled, dependency-free CLI, and the backup worker runs inside the runtime process — never a separate worker process. runner is a minimal node:24-alpine image with only the traced Next.js standalone output — no bin/, scripts/, tsx, or full node_modules — so runtime/src/backup-run.ts's runInstanceBackup cannot shell out to pnpm sv backup there (it ENOENT'd in production until epic task 8.16). Instead bin/backup-restore.ts holds the backup/restore command logic (shared unchanged with bin/sv.ts), bin/sv-backup-cli.ts registers only those two commands, and tsup (bin/tsup.config.ts, pnpm run build:cli) bundles it into runtime/dist-cli/sv-backup-cli.js, which the Dockerfile copies into runner and runInstanceBackup spawns with plain node when present, falling back to pnpm sv backup on native pnpm dev/tools checkouts. Consequences: those two bin/ files never import runtime/src or @sovereignfs/db (two trivial helpers from packages/db/src/{dialect,client}.ts are duplicated locally rather than pulling that barrel — drizzle-orm/cron-parser/@libsql/client/the native better-sqlite3-multiple-ciphers addon — into a bundle that needs two env-var reads), and any dependency added there must be pure JS and listed in noExternal. Moving the worker to a separate tools-based process was considered and rejected: runtime/src/portability/registry.ts's export-hook registry is populated by request-scoped plugin code as pages load, never at boot, so a headless worker process sees an always-empty registry and silently produces user-scope backups missing every plugin's data. runner also installs git and postgresql16-client (version-pinned to docker-compose.postgres.yml's postgres:16-alpine) — previously in neither runner nor tools, which would have ENOENT'd the user-scope git-push/restore-fetch path (runtime/src/git-backup.ts) too, confirmed empirically (docker run --rm node:24-alpine sh -c "git --version" → not found). Postgres dialect only — SQLite (sqld) instance backup/restore has no CLI implementation yet; building it needs its own RFC first, per docs/research/0017-sqld-backup-and-restore.md's gate.

  • packages/db/src/bootstrap.ts is a second, hand-written schema definition for at least instance_config and backup_jobs, and it does not automatically stay in sync with schema/{sqlite,postgres}/platform.ts or the generated Drizzle migrations. Its own doc comment calls it an "interim DDL bootstrap... replaced by drizzle-kit migrations later (0.5.05+)" — that replacement never fully happened for every table, so platformBootstrapStatements()'s CREATE TABLE IF NOT EXISTS DDL is still what actually creates these tables' columns wherever migrations aren't run first (found live: CI's Postgres-dialect test job spins up a bare postgres:16 container and calls bootstrapPlatformDb() directly, never drizzle-kit migrate — packages/db/src/__tests__/platform-db.pg.test.ts is gated behind describe.skipIf(!PG_URL), so this path only executes where TEST_DATABASE_URL is set, which is true in CI but not in a typical local run, letting the gap through code review and a clean local pnpm exec vitest run). Adding a real column to one of these tables (RFC 0095's instance_config.theme_preset, mirroring RFC 0077's brand_radius precedent) means updating three places, not two: both dialect schema files, the generated migration, and this file's matching CREATE TABLE IF NOT EXISTS statement — verified by reproducing the exact CI failure locally (docker run postgres:16 with matching credentials, TEST_DATABASE_URL=... pnpm exec vitest run packages/db/src/__tests__/platform-db.pg.test.ts) before and after the bootstrap.ts fix, not just inferred from the error message.

  • A storage object uploaded with ownerUserId set is unreadable from any job/schedule handler, and there is no background-context fallback that fixes this (found live building sovereign-plugin-travellog's Swarm importer, RFC 0044/0046). packages/db's canAccessStorageObject denies read access whenever ownerUserId is set and the reading context's userId doesn't match — including null — and a job/schedule handler's userId always resolves to null: JobContext (packages/sdk/src/types.ts) carries a plugin id, never a user id, so unlike sdk.db.getClient()'s pluginId (which falls back to getBackgroundPluginContext(), an AsyncLocalStorage populated by runtime/src/jobs.ts/scheduler.ts), there is no equivalent identity to fall back to for userId — a job is plugin-scoped, not inherently user-scoped, so the platform has nothing trustworthy to populate it with even if it wanted to. sdk.storage.put() a plugin intends to read back from a job/schedule handler (as opposed to from a real request, e.g. getSignedUrl calls issued to render a page) must omit ownerUserId entirely; the plugin's own DB rows (tenant/user-scoped) remain the actual access-control boundary for who can ever learn that storage key, same as every other per-user resource. The tradeoff: an unowned object is invisible to hardDeleteUserStorageObjects (RFC 0033's cross-plugin owner_user_id sweep on account deletion) and will not be automatically cleaned up by it — a plugin relying on this pattern needs its own deletion path (e.g. a sdk.portability.provideDelete handler) for those keys. Confirmed live: the upload succeeded and the row/bytes were both genuinely present (verified via a raw table dump on the same DB connection, bypassing Drizzle), yet every import job failed with "no longer available in storage" until ownerUserId was dropped from that one put() call.

  • A plugin's schedules/jobs/events handler must be imported from its real source file, never its composed route-tree copy — found via a real production deployment failure reported directly (image build succeeded, deploy failed), not live-testing. scripts/generate/plugin-schedules.ts, plugin-jobs.ts, and plugin-events.ts each generate one static import handlerN from "<path>" per manifest-declared handler into runtime/generated/plugin-{schedules,jobs,events}.ts. All three previously computed <path> from resolveComposeTargets(manifest)'s targets[0] — the plugin's composed app directory (runtime/app/(platform)/(plugins)/<id>/... or (minimal)/<id>/...), which compose-routes.ts's linkOrCopyTarget makes a real symlinkSync in production (isProd = NODE_ENV === 'production'), not a physical copy. runtime/tsconfig.json's own exclude list — app/(platform)/(plugins)/**, app/(minimal)/** — deliberately keeps composed plugin directories out of the project's normal type-check scope, but runtime/generated/*.ts is not excluded (it's covered by the include glob generated/**/*.ts), so a generated handler import reaches into the symlinked tree from outside it. TypeScript resolves that file's own further relative imports (e.g. a sibling _lib/ids.ts's import 'nanoid') against the symlink's apparent location when the file is reached this way — walking up through runtime/'s own node_modules — instead of following the symlink to its real target the way Next's own App Router route-file discovery does. runtime/package.json doesn't declare every package an arbitrary plugin might import (it does directly redeclare drizzle-orm, since every plugin's schema/queries need it, but had never needed to redeclare nanoid), so resolution silently fails with TS2307: Cannot find module — but only for a plugin whose schedule/job/event handler (transitively) imports something not on that list, which is why this went unnoticed: sovereign-plugin-ledger was the first plugin ever to combine a manifest schedules declaration with such an import; sovereign-plugin-tasks (the only other schedule-declaring plugin) never hit it because its own job handler doesn't happen to import anything runtime doesn't already have. Root-caused via an isolated git worktree reproduction of the exact failing CI build — deliberately not run against the live dev server sharing the actual working directory, and not run with NODE_ENV=production there either, both to avoid disrupting it — that reproduced the identical error byte-for-byte, then proved it wasn't a general nanoid-resolution problem by deleting just the one plugin's import and watching every other nanoid-importing plugin (Kanban, Docs, Sheets — identical import pattern) compile successfully in the same build; tsc -p tsconfig.json --traceResolution on the generated file's own transitive import chain then confirmed the exact symlink-vs-real-path mechanism, module by module. Fixed at the generator, not with a runtime/package.json redeclaration band-aid (which does also work and was verified independently, but only patches this one instance — any future plugin combining a schedule/job/event with a not-yet-redeclared dependency would hit the identical failure): all three collectPlugin{Schedules,Jobs,Events} functions now import from srcFile (the plugin's real source path, already computed for the existence check just above) instead of a composedFile built from targets[0]. The resolveComposeTargets call itself stays — it still performs real validation (rejecting a multi-segment routePrefix on an overlay-shell plugin) — only its targets[0] result is no longer used for the import path. Verified via the same isolated-worktree reproduction with the fix applied (the full production build succeeds, /ledger's route compiles into real server output), the three generators' updated unit tests (scripts/__tests__/generate-registry.test.ts), and the full existing test suite.

  • A Postgres session-level advisory lock (pg_advisory_lock/ pg_advisory_unlock) must be acquired and released on the exact same physical connection, or the unlock silently no-ops and the lock leaks forever. packages/db/src/migrate.ts's runMigrations() used to route the lock and unlock through two independent dbGet(pdb, ...) calls; since pdb.db (Postgres) is backed by a pg.Pool, each call is free to be served by a different physical connection, and pg_advisory_unlock only releases a lock held by the calling session — issued from the wrong connection, it does nothing, with no error. This caused a real production outage: a stale lock from one boot (an unrelated redeploy's restart lost the pool's race) hung every subsequent runMigrations() call — i.e. every later container restart — indefinitely, taking the entire app down on every request, since Next.js never finishes preparing the server. Diagnosed live via pg_stat_activity/pg_locks against the affected production database; resolved immediately by terminating the stuck holder session (pg_terminate_backend). Fixed by checking out one dedicated client for the whole lock→migrate→unlock window (pdb.db.$client.connect() — $client is drizzle-orm's own exposed raw Pool, confirmed via its .d.ts) instead of letting the pool route each statement independently; migratePg/seedPlatformData keep using the shared pool as before, since only the lock/unlock pair needs session affinity. The general lesson extends beyond this one call site: any session-scoped Postgres primitive (advisory locks, SET search_path/session GUCs meant to persist across statements, temp tables, pg_backend_pid()-keyed state) is unsafe to use against a pg.Pool-backed Drizzle client without explicitly pinning a single checked-out client for the whole sequence — a bare db.execute()/ db.query() call gives no such guarantee, silently, even though it looks identical to a normal one-shot query. Verifying the fix under CI-representative conditions (the full .pg.test.ts suite run together, matching CI's one shared Postgres services: container) surfaced two more, independent, pre-existing test-fixture bugs in the same area, fixed alongside it: migrate.pg.test.ts derived migrationsTable names by concatenating a fixed prefix onto a full 36-char UUID-based schema name, silently overflowing Postgres's 63-byte NAMEDATALEN identifier limit into truncated, colliding names once several tests ran concurrently; and plugin-isolation-migration.pg.test.ts's afterEach ran a blanket DROP SCHEMA "drizzle" CASCADE — the one fixed schema Postgres's migrator always tracks applied migrations in, regardless of any connection's search_path — which raced and deleted other concurrently-running test files' own in-use migrations tables mid-run. Both are instances of the same underlying hazard migrate.ts's own doc comment already flagged for one describe block in that file (a blanket schema-wide DROP/CASCADE against the shared drizzle schema is never safe when other .pg.test.ts files run concurrently against it) but that had not been generalized to every file using that schema. Regression coverage: a new test independently probes the lock via pg_try_advisory_lock from a completely separate connection after runMigrations() returns, confirmed to fail against the pre-fix code by temporarily reverting the fix.

  • checkAdminKey() (runtime/src/admin-guard.ts, apps/auth/src/admin-guard.ts) compares the SOVEREIGN_ADMIN_KEY bearer token with crypto.timingSafeEqual on length-checked buffers, never plain !==. This is the sole authorization boundary for the entire /api/admin/* surface on both services — runtime/proxy.ts's matcher deliberately excludes this path (self-authenticated by design), so a plain string comparison is also a timing side-channel with nothing else standing in front of it. A dedicated per-IP rate limiter (admin-rate-limit.ts, one instance per service, duplicated rather than shared across the service boundary) backs the comparison: it counts only failed comparisons, never successful ones — checkAdminKey is the single call point behind every /api/admin/* route (e.g. Console's adminFetch, called with the correct key on every admin action), so counting successes too would throttle legitimate traffic instead of just repeated bad-key guesses. Once an IP trips the limiter, every request from it returns 429 until the window resets, including one presenting the correct key. checkAdminKey() is also the only thing these routes may trust: because the matcher skips this path, the x-sovereign-user-id/-role headers middleware injects elsewhere are never set or stripped here — a caller can forge x-sovereign-user-role: platform:owner with curl -H, and three routes were found doing exactly that (fixed in 0.94.15). The one route that cannot carry an Authorization header (the email-template preview <iframe>) verifies the real session cookie in-route via verifySession instead.


Rules promoted from the CLAUDE.md release log (2026-09-06) ​

These were each learned in a specific task and previously recorded only in CLAUDE.md's per-release narrative (now archived in docs/task-history.md). Each is a genuine, reusable constraint; the originating release is noted so the full story is one grep away.

  • A deletion handler severs cross-user attribution; it never deletes the row and never writes a sentinel id (RFC 0097). sdk.portability.provideDelete handlers are usually written as "delete everything scoped by owner_user_id", which silently ignores a third class of row: one another user owns but the departing user is attributed on (shopper_list_items.added_by, shopper_purchases.purchased_by, sovereign-tasks' assignee_id). Deleting it corrupts the owner's data; leaving it keeps a deleted account's id in the table. Set the attribution column to null and count those rows in DeletionResult.anonymized, never in deleted — a severed row still exists, and deleted is the number an operator cites to evidence an erasure. Attribution columns are declared nullable; ownership columns (owner_user_id, user_id) stay NOT NULL and their rows are deleted outright. A "deleted user" sentinel id was considered and rejected: it is invisible to the type system (every existing read site keeps compiling), must be excluded by hand from every filter and aggregate forever, and buys nothing at render time because sdk.directory.resolveUsers() returns nothing for it either way. The platform already answered this question the same way for activity_log.actor_id (nullable, paired with an actor_type discriminator). Relaxing an already-shipped NOT NULL column is a plain drizzle-kit generate in both dialects — the "hand-author the migration" rule covers renames, which drizzle-kit cannot do non-interactively, not nullability changes.
  • A plugin declaring data:import must register sdk.portability.provideImport(), and data:export must be paired with provideExport(). The restore path looks up handlers by plugin id at import time and skips a section with no handler ("no import handler registered for this plugin") rather than failing, so a manifest can claim a permission the plugin never honours and a user's data silently fails to come back. Every first-party plugin that declares both permissions implements both handlers symmetrically. Found in 0.130.2 (Warden had declared data:import since epic 22.5 with no handler).
  • runtime/proxy.ts is Next's proxy convention (the renamed middleware, Next 16) and runs on the Node.js runtime — the Edge runtime is not supported there. Decision (epic task 0.29): it keeps the Edge discipline as a convention — no Node built-ins, no ioredis, no DB access, sessions verified from the signed cookie cache with /api/verify as the only network fallback. Reasons: the gate stays portable to an edge/CDN deployment (Next's own guidance for proxies), the offline verify path stays the hot path with no per-request DB round trip, and the regression suite (runtime/src/__tests__/proxy-regression.test.ts) assumes exactly these constraints. Relaxing any of them is a deliberate change with its own task, not a convenience. The historical rule follows. It cannot load ioredis (needs Node net/tls) or any Node built-in, and it avoids DB writes for the same reason. Moving it to Node.js Middleware is a platform-wide change with its own blast radius (paused task 2.29, RFC 0086 open question), not something to fold into another task.
  • node-postgres returns bigint columns and COUNT(*) as JS strings from raw queries unless a global type parser is registered. row.started === now comparisons silently fail; Number() before comparing. Bit twice: HMAC rotation (0.102.0) and unread-notification counts (0.120.0).
  • Never fork a navigation decision by viewport. ResponsiveSurface's useIsMobile() returns false on first paint and corrects in a useEffect; React runs child effects before parent effects in the same commit, so a redirect inside the "desktop" branch fires on real phones too, before the corrective effect runs. Fork only what renders — Console's bare /console and Account's bare /account both work this way. 0.119.0.
  • A React element produced by a Server Component and stored into a Client Component's state loses its key. It crosses the RSC boundary as an opaque Flight reference object with no .key, so a downstream Children.toArray/reconciliation sees a stable positional key and updates the existing fiber in place — defaultValue-seeded inputs stay frozen on the first selection. Thread a plain string key prop through props/context and apply it as key on a Fragment the client creates itself. 0.114.2.
  • A spawned git never inherits a repository from the environment. Git exports GIT_DIR (and can export GIT_WORK_TREE, GIT_INDEX_FILE, …) to the processes it spawns — hooks above all: a pre-push hook run from a linked worktree gets GIT_DIR=<main>/.git/worktrees/<name>. Code that addresses a repository by cwd on a fresh temp directory — the backup push in runtime/src/git-backup.ts, the pinned-ref clone in scripts/install-plugins.ts — then silently operates on the inherited repository: git init re-initializes it, add/commit/tag land there with the temp dir as the work tree. Found the hard way: the test suite, run by the pre-push hook from a worktree, committed its backup fixtures onto the branch being pushed and flipped the shared config to core.bare = true. Build the child environment with gitChildEnv() (strips GIT_REPOSITORY_ENV_VARS), in tests too — the regression test in git-backup.test.ts sets GIT_DIR to a throwaway repo and asserts it stays empty.
  • Credentials for a spawned git go through the environment. GIT_ASKPASS script for HTTPS tokens, GIT_SSH_COMMAND with an IdentityFile written to a 0600 temp file and removed in finally for SSH keys — never argv, never an embedded URL, both of which are visible to every user on the host via ps. runtime/src/git-backup.ts is the reference implementation. 0.124.0.
  • packages/ui components are framework-agnostic. No next/navigation or next/link imports; active state comes from the consumer (item.active) and links via an optional renderLink. NavTabs and NavList both follow this; an epic task draft assuming NavList would call usePathname() internally was corrected before it shipped. 0.105.0.
  • drizzle-kit generate cannot run non-interactively for a rename. A table or column rename triggers its own TTY prompt ("renamed or created new?"). Hand-author the migration for both dialects in drizzle-kit's own output format, then verify two ways: drizzle-kit check (snapshot/journal consistency) and a follow-up drizzle-kit generate that reports "No schema changes". 0.103.0.
  • Third-party plugin dependencies are never committed to runtime/package.json or runtime/generated/plugin-deps.json.sovereign.plugins.json is gitignored per checkout, and hoisting (RFC 0057, bin/plugin-deps.ts) runs on the operator's own checkout via sv plugin add, scripts/install-plugins.ts, and pnpm dev's .local sync. A first fix attempt that committed travellog's deps directly was reverted as exactly the anti-pattern the mechanism exists to avoid. 0.101.7.
  • sdk.* permission checks reject a missing plugin id. A getPluginId(headers) ?? 'unknown' fallback laundered a missing identity into a no-op check in sdk.notifications.send(); the check must fail closed. 0.120.0.
  • Every outbound fetch on a server render path has a timeout, and a route that blocks on network has a loading.tsx. An unbounded health probe left /warden rendering nothing for the duration of a stalled upstream; with a 3s timeout plus loading.tsx, the first HTML chunk arrived in 7ms while the slow discovery still took 8s in the background. 0.112.1.
  • router.push() to the route's own current URL does not refetch server data — use router.refresh(). And a callback passed as a useEffect dependency must be memoized (useCallback), or a parent re-render re-fires the effect; guard success effects with a ref keyed on the state object so a forgotten memoization upstream can't replay a toast. 0.112.4–0.112.5.
  • Overriding a DS component's own module class needs higher specificity..cardChevron { display: none } vs Icon.module.css's .root { display: inline-block } is a tie decided by Next.js's per-route CSS bundle order, which differed between /console/groups and /console/oauth-clients. Qualify the selector (svg.cardChevron). 0.111.0.
  • A composer-style surface keeps a stable element tree across state transitions. Rendering the composer inside a wrapper that only exists in the empty state remounted the whole subtree — including the incognito toggle — every time the empty/non-empty boundary was crossed. Keep the siblings permanent and swap a modifier class. 0.110.0.
  • citty defineCommand args have no repeatable type — it delegates to node:util's parseArgs with multiple never set, so a repeated flag silently keeps the last value. Read rawArgs for a flag that may repeat (--exclude-plugin). 0.128.0.
  • A better-auth version bump is verified against a populated auth store, never only a fresh one, and never merged from a grouped Dependabot PR. better-auth's migrator (dist/db/get-migration.mjs) refuses to add a required column with no default to a table that has rows — it throws UnsafeMigrationError, which apps/auth/src/migrate.ts's runAuthMigrations() propagates, so the auth server fails to boot on every real instance while a fresh CI database passes. better-auth 1.7.0–1.7.2 did exactly this with a required account.issuer column (and keyed credential sign-in on issuer === 'local:credential'), then 1.7.3 removed it again per better-auth's own 1.7 upgrade guide; dependabot.yml ignores that window and the three packages have their own Dependabot group. Two genuine session_data cookies captured from real 1.6.25 and 1.7.2 instances (runtime/src/__tests__/fixtures/) are permanent regression fixtures for the compact cookie-cache format runtime/proxy.ts verifies offline — a hand-forged partial record is not a valid fixture (1.7 validates the payload against better-auth's own record schemas and rejects it). Epic task 1.26 holds the re-entry checklist.
  • vitest.config.ts aliases every next/* import to runtime/node_modules/next, and that alias is load-bearing. In a real build Next's own webpack config aliases next to the running package, so packages/sdk's import { headers } from 'next/headers' and the app share one module and one request-scope AsyncLocalStorage. Vitest has no such alias by default: each importer resolves next through its own node_modules symlink, and a pnpm lockfile that has split next into several peer-suffix variants (the routine Dependabot batch #642 did, on an @types/node 22-vs-26 suffix the tree already carried) hands runtime test files one physical next/headers and packages/sdk another. vi.mock('next/headers') patches the test file's copy; the SDK's real headers() runs outside any request scope, throws Next's E251, the catch treats it as "no request", and every sdk.db/sdk.storage/sdk.env background-fallback test fails with "expected 'platform-db-marker' to be 'plugin-db-marker'" — while a single-variant lockfile passes by accident. Reproduced by rebuilding #642's exact tree (base commit + diff, pnpm install --frozen-lockfile): 8 of 18 routing tests failed, all 18 passed with only the alias added. The alias makes the mock target the same module by construction, regardless of how the lockfile resolves.
  • Test hygiene learned the hard way: await every async operation a test starts (an un-awaited jsdom FileReader completion lands as an unhandled exception after the test returns and fails the whole run, 0.94.14); scope a long testTimeout to the one slow test (0.94.13); a .pg.test.ts using an isolated schema must mock modules that resolve the real getPlatformDb() singleton internally — sendPlatformEmail, fanOutPushToUser (0.120.0, 0.123.0); keep Postgres identifiers under 63 bytes or they truncate silently and collide (0.101.9).

Open source under AGPL-3.0. Each Sovereign instance is independently operated.