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, afterplugins/consolewas 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 usepackages/sdkonly.plugins/consoleis the one documented exception to theruntime/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/mailerrestriction still fully applies (eslint.config.tsgives it its own, narrower rule block, not a blanket exclusion). Anything Console needs from those packages goes behind an admin-key-authenticatedruntime/app/api/admin/*route it calls over HTTP (theentitlements/actions.tspattern) —enqueueBackupJob/listBackupJobshad 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 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.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/sdkminor, not patch. Same bar as widening an existing field's type (e.g.StorageContext.pluginId, RFC 0044). Not just a semver formality: it breakspnpm typecheckimmediately, not just in theory. Five hand-rolled mockSdkHostobjects 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 realruntime/src/sdk-host.ts. Found generalizing thesdk.storage/sdk.db.getClient()background-invocation fallback tosdk.env.get(), which needed a newenvhost 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 — aplugin_<slug>Postgres schema or sqld namespace — since epic task 8.28 retired thedatabase.isolation: "shared"manifest option. Only the platform-type plugins (Console, Launcher, Account) write to the platform DB.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. (SQLite is served by sqld over@libsql/client, which is itself async — no synchronous SQLite path is left in the live server;better-sqlite3survives only in the one-time legacy-file readers behindsv db migrate-to-postgresand 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, viafindWorkspaceRoot()), 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.dbfiles thatsv db migrate-to-postgresand 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 bothpackages/dbandapps/auth(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. The platform plugins in this monorepo have notypecheckscript of their own either (cd plugins/console && npx tsc --noEmitsilently resolves an unrelated root tsconfig), sopnpm typechecknever compiles a composed plugin's route files — onlypnpm --filter runtime builddoes; a wrong prop name on a@sovereignfs/uicomponent 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@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|auto|fixed, defaultlg); the@modal/layout.tsxresolves it from the selected interception segment (overlaySizeForSegmentinruntime/src/overlay.ts).lgis 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 toshell: "default"; thePluginAccessDialog/CapabilitiesButtonexamples below are ordinary in-pageDialogusages inside Console, unaffected by that shell change).fixedis also a true fixed-size box (width AND height both set, content never resizes it) but capped at64rem × 44remrather than filling the viewport — the size Account (epic task 14.5) actually renders into: algbox 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/accountroute client-side redirects to/account/profilethrough a near-empty intermediate render, andauto'sfit-contentvisibly 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 viewportlgleft empty).autoremains 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 atmin(48rem, 100%), and a plugin adopting it should still give its own root element an explicitwidthat (or under) that cap and a lightmin-heightfloor, since otherwisefit-contentmeasures 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 wayfixed's true fixed box does.sm/mdare 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.lgfills the viewport minus a fixed margin, and is centred and capped the same waymd/smare; 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.Composed route pruning is recursive to the leaf directory, not top-level-only — a multi-segment
routePrefix(shell: minimalexplicitly 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 runsassertNoOrphanedRouteDirectories()after composing/pruning — agenerate-time consistency check that walks the composed tree a second, independent way (by presence of apage.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 thepage.tsx/layout.tsxheuristic — checking the heuristic first produces false-positive orphan reports on a plugin's own internal Next.js route groups (e.g.(home)), which have noroutePrefixmapping of their own but sit above a realpage.tsx.shell: defaultplugins can independently hide the mobile header and/or footer viashellConfig.mobileHeader/shellConfig.mobileFooter(RFC 0075, both boolean, defaulttrue) — this is a per-request runtime branch inside(platform)/layout.tsx, not build-time route-group composition (unlikeoverlay/minimalabove). 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 setsx-sovereign-mobile-header/x-sovereign-mobile-footer(mirroring the offline-route flag below) only when a value deviates from the default;(platform)/layout.tsxomits 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, andMobileNav.module.css's.footernow each declare an explicitgrid-rowso 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-heightcollapse to0pxvia.shell[data-mobile-header-hidden]/[data-mobile-footer-hidden]attribute selectors (higher specificity than the base.shellrule, 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 forcerouter.refresh()when a client-side navigation crosses a mobile-chrome visibility boundary, using the sharedmobileHeaderVisible/mobileFooterVisiblehelpers inruntime/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-topinline style on transition, sincesyncViewport()'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.adminOnlyroutes are gated in the runtime middleware. A request under an admin-only plugin'sroutePrefixfrom a user without theconsole:accesscapability (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 theadminOnlycheck the middleware applies to/consolecannot be assumed to cover it. Every action begins withconst session = await sdk.auth.requireSession();and an explicitsdk.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 inplugins/console/app/**/actions.ts, whoseadminFetchhelper attachesSOVEREIGN_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.tsshipped with no check at all while every sibling action file had one (found by CodeQL alert #1, which flagged theexecSyncin it and not the missing authorization).Never interpolate a request-derived value into a shell command string. Use
execFileSync/execFilewith 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 nogflag 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+. Droppinggor splitting/^-+|-+$/ginto 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. Seeslugify()inruntime/app/api/admin/groups/route.tsandslugifyVitePressHeading()inscripts/check-doc-links.ts.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 (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 thebootstrap.tsbullet below; a new column needs both.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.A manifest
disabled: trueplugin 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 checkingbypassPluginVisibilityInDev()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 aplugin_statusrow for one at all, andrunAllPluginMigrations()(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 isCHROME_PLUGIN_IDSinruntime/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'splugin_statusrow in either direction, andgetPluginCatalog()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 upenabled: falseregardless (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 importingruntime/srcor internal packages. The route is session-gated (middleware injectsx-sovereign-user-role) and role-filters viaselectLauncherPlugins. Launcher, Account's portability panel, and Console's plugin actions all use this route;sdk.dbis 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 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/page.tsxis a page route that only servesGET, 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 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.)Profile self-mutations must clear both
session_datacookie variants (better-auth.session_dataand__Secure-better-auth.session_data, each withmaxAge: 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 inruntime/app/api/account/avatar/route.tsandplugins/account/app/actions.ts.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/proxy.tsextends the session middleware;apps/auth/proxy.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). 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 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.@sovereignfs/ui'sSwipableMobileCarousel/SwipableMobileCarouselSlideBodyalready account for this (see that component's CSS) — a new vertically-scrolling slide body should not need to touchtouch-actionat 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 intoruntime/public/sw.jsbyruntime/scripts/build-sw.tsafternext 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/staticfor the precache manifest), which is what let the runtime drop the webpack-only@ducanh2912/next-pwaand build with Turbopack under Next 16. The SW is disabled in dev (nothing builds it, andapp/_components/ServiceWorkerRegistration.tsxunregisters 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/offlinefallback are excluded from the middleware session gate (they must load without a session). A plugin declaringinstallable: true(RFC 0081) gets its own manifest at/api/manifest/[pluginId], nested under the already-reserved, already session-exemptmanifestAPI 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. Themanifest[].iconsarray's own URLs need the identical exemption, on a different matcher entry —runtime/proxy.ts's matcher must excludeplugin-icons/alongsideicons/, 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 (curlagainst a running instance) before the matcher entry was added, task 2.26.runtime/src/plugin-manifest.ts'sbuildPluginManifestIcons()andscripts/generate-registry.ts'scopyPluginIcons()are the two halves of this: the former lists an icon URL only if the latter is guaranteed to have written that exact file (aniconfallback, or a matching author-suppliedicons.*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#09090bboth manifest routes (instance-level and per-plugin) already hardcode astheme_color/background_color— there is no per-instancebackground_colorconfig field anywhere in the platform today (onlyinstancePrimary/instanceRadiusdo), 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 anofflinetier) is handled by Serwist'sNetworkOnly, a strategy with no cache to read or write, bounded bynetworkTimeoutSeconds; on any failure the fallback plugin serves the precached/offlinepage. There is no cached document to ever replay to the wrong user, because none exists. (Under next-pwa this was aNetworkFirstwhosecacheWillUpdatereturnednull, because workbox-build's config schema rejectednetworkTimeoutSecondsonNetworkOnly; 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 separateoffline-shellsentry — safe because that document is required to be a user-neutral shell (enforced byruntime/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 — seeruntime/src/registry.ts'sgetOfflineRoutePrefixes().A signed, per-user offline session assertion previously partitioned the
pagescache 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 pastpnpm testand 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 —pagesis 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. Seedocs/research/0011-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 avatar and other on-disk writes work with zero hostchown; database data itself lives in thesqldservice's ownsovereign_sqld_datavolume (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 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/datavolume. Without the markerfindWorkspaceRoot()falls back to the post-chdircwd: 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 thesqldservice'ssovereign_sqld_datavolume.) 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).sdk.device.getSurface()/x-sovereign-surface(RFC 0080), andx-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'sdecideFocusRoute(), wired intoruntime/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.tsstrips any inboundx-sovereign-surface/x-sovereign-shell-version/x-sovereign-focus-pluginheader before injecting its own (all parsed from the shell's singleSovereign-Shell/<mobile|desktop>-<platform> <version> (focus=<pluginId>)User-Agent token viaruntime/src/surface.ts— RFC 0082 deliberately extends RFC 0080's existing token rather than inventing a second one), the same discipline already applied to thex-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/authreads the exact sameDB_DIALECT/POSTGRES_DB_URLthe platform does — no separate auth-specific database variable. This replaced an earlier design where auth resolved its own dialect independently from a separateAUTH_DATABASE_URL, which was a real production gap: an instance whose platform core had already been migrated to Postgres (DB_DIALECT=postgres) could leaveAUTH_DATABASE_URLunset, 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'ssearch_pathstartup 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/authstill 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 torunPluginMigrations()— the untouched default collides across plugins. drizzle-orm's node-postgres migrator tracks applied migrations in a table that lives in a fixeddrizzleschema, regardless of the connecting pool'ssearch_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 separateplugin_<slug>schemas viasearch_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 everyCREATE TABLEstatement 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 bypluginDb.dialect === 'postgres', exactly asruntime/src/plugin-migrations.tsand bothbin/sv.tsisolated-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-SHA256header.payload.signaturetoken (hand-rolled withnode:crypto, no library), verified withtimingSafeEqual, no persistent state, revocation via secret rotation. First applied beyondapps/relayitself inapps/harness/src/enrollment.ts(RFC 0063, workstream 0014 leg 2) —apps/harnessand Warden's server-side code authenticate each other this way,SOVEREIGN_HARNESS_ENROLLMENT_SECRETin place ofRELAY_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/harnesshas 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
entrypointoverride —depends_ononly orders container start, not readiness for an arbitrary precondition like a downloaded file landing on a shared volume.docker-compose.yml'sharness-engineservice (llama.cpp server) is the example:-m <path>is a required startup arg with no built-in wait behavior, so itsentrypointpollsuntil [ -s /models/model.gguf ]beforeexec-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.tsstarts the Next.js server immediately and downloads the GGUF model in the background;/api/healthexposesmodelStatusseparately 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'sPageContainer, never from localpadding/max-widthinapp/layout.tsxor 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 skipsPageContainerrenders edge-to-edge; a plugin that pads itself and wraps inPageContainerdouble-pads (the exact bug this rule exists to prevent — every plugin underexample-plugins/was double-padded until task 9.25 fixed both the plugins and the two scaffolders,bin/helpers.tsandpackages/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 bothDialog(soft navigation) and the shell's full-page fallback (hard navigation) —Dialog's content region sets--sv-page-gutter: 0, whichPageContainerreads 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 viadata-plugin-fullbleed(sovereign-tasks,sovereign-shopper) manage their own layout and don't usePageContainerat all. Full detail:docs/design-system.md's "Page layout" section.A
position: stickyelement 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: hiddenalone 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 anyposition: stickyelement 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 onposition: stickyelements 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 (getComputedStyleresolves thetransform), not by reproducing the underlying flash in this tooling. Found live (sovereign-tasks, viaffmpegframe-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 otherposition: stickyelement found to be at real risk (mobile, inside scrolling content):@sovereignfs/ui'sMobileHeaderandSystemBanner, and theaccount/consoleplatform 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, theshellConfig.mobileHeader/mobileFooter: falsepattern, RFC 0075/0088) publishes its own measured height as--sv-shell-header-height/--sv-shell-footer-heighton#sv-app-shellautomatically — both components callusePublishShellChromeHeightinternally (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 own0pxoverride (shell.module.css'sdata-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, andDialogall size themselves against these two variables to stop above/below the chrome instead of sliding underneath it; with the variable stuck at0px, they extended full-height, and since the header/footer's ownz-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 own0pxcollapse, 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 — ifMobileHeader/MobileFooterever stop covering a real layout, extend the shared hook, not the plugin.Prefer a synchronous
getBoundingClientRect()read (inuseLayoutEffect, re-run on awindowresizelistener) overResizeObserverfor measuring an already-rendered element's size. In live testing, a freshly createdResizeObservernever 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 thatResizeObserveris broken — only that it has been observed to silently fail in exactly this scenario in this codebase's own tooling, and agetBoundingClientRect()-based measurement worked immediately and reliably as a drop-in replacement both times it was tried. If aResizeObserver-based measurement isn't firing and the element is provably rendered with a stable, non-zero size, don't spend time adding moreResizeObserverdebugging — switch to this pattern first.usePublishShellChromeHeight(above) uses it.A signed-download route's token lives in its own
[token]path segment, never inparamswithout a matching folder and never read offparamsat all if the route was only ever given a[jobId]-shaped directory. Found inruntime/app/api/backup-jobs/[jobId]/download/route.ts(RFC 0084, epic task 8.16): the handler destructuredtokenfromparams, but the route's own directory only had a[jobId]segment — no[token]folder existed, sotokenwas alwaysundefinedand 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 fromparams, verified by checking the actualapp/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 aruntime/proxy.tsmatcher exemption "by design" — modeling itself onapi/storage's already-correct exemption — butapi/backup-jobswas never actually added to the matcher's negative-lookahead list. A request carrying a genuinely valid, unexpired signed token still 303-redirected to/loginbefore the route's own token check ever ran, because middleware intercepted it first; reproduced live viacurlwith 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 realmatcherarray inruntime/proxy.tsdirectly before trusting it. Fixed by addingapi/backup-jobsto that array — safe here specifically because, likeapi/storage, no other route shares this prefix (unlikeapi/instanceabove, 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 exercisesconfig.matcherdirectly (runtime/src/__tests__/middleware-regression.test.ts'smiddleware matcherdescribe block) rather than relying on a doc comment — confirmed to fail against the pre-fix matcher before being trusted.The production
runnerimage runs instance backup/restore through a bundled, dependency-free CLI, and the backup worker runs inside the runtime process — never a separate worker process.runneris a minimalnode:24-alpineimage with only the traced Next.js standalone output — nobin/,scripts/,tsx, or fullnode_modules— soruntime/src/backup-run.ts'srunInstanceBackupcannot shell out topnpm sv backupthere (itENOENT'd in production until epic task 8.16). Insteadbin/backup-restore.tsholds thebackup/restorecommand logic (shared unchanged withbin/sv.ts),bin/sv-backup-cli.tsregisters only those two commands, andtsup(bin/tsup.config.ts,pnpm run build:cli) bundles it intoruntime/dist-cli/sv-backup-cli.js, which the Dockerfile copies intorunnerandrunInstanceBackupspawns with plainnodewhen present, falling back topnpm sv backupon nativepnpm dev/toolscheckouts. Consequences: those twobin/files never importruntime/srcor@sovereignfs/db(two trivial helpers frompackages/db/src/{dialect,client}.tsare duplicated locally rather than pulling that barrel —drizzle-orm/cron-parser/@libsql/client/the nativebetter-sqlite3-multiple-ciphersaddon — into a bundle that needs two env-var reads), and any dependency added there must be pure JS and listed innoExternal. Moving the worker to a separatetools-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.runneralso installsgitandpostgresql16-client(version-pinned todocker-compose.postgres.yml'spostgres:16-alpine) — previously in neitherrunnernortools, which would haveENOENT'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, perdocs/research/0017-sqld-backup-and-restore.md's gate.packages/db/src/bootstrap.tsis a second, hand-written schema definition for at leastinstance_configandbackup_jobs, and it does not automatically stay in sync withschema/{sqlite,postgres}/platform.tsor 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, soplatformBootstrapStatements()'sCREATE TABLE IF NOT EXISTSDDL 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 barepostgres:16container and callsbootstrapPlatformDb()directly, neverdrizzle-kit migrate—packages/db/src/__tests__/platform-db.pg.test.tsis gated behinddescribe.skipIf(!PG_URL), so this path only executes whereTEST_DATABASE_URLis set, which is true in CI but not in a typical local run, letting the gap through code review and a clean localpnpm exec vitest run). Adding a real column to one of these tables (RFC 0095'sinstance_config.theme_preset, mirroring RFC 0077'sbrand_radiusprecedent) means updating three places, not two: both dialect schema files, the generated migration, and this file's matchingCREATE TABLE IF NOT EXISTSstatement — verified by reproducing the exact CI failure locally (docker run postgres:16with 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
ownerUserIdset is unreadable from any job/schedule handler, and there is no background-context fallback that fixes this (found live buildingsovereign-plugin-travellog's Swarm importer, RFC 0044/0046).packages/db'scanAccessStorageObjectdenies read access wheneverownerUserIdis set and the reading context'suserIddoesn't match — includingnull— and a job/schedule handler'suserIdalways resolves tonull:JobContext(packages/sdk/src/types.ts) carries a plugin id, never a user id, so unlikesdk.db.getClient()'spluginId(which falls back togetBackgroundPluginContext(), anAsyncLocalStoragepopulated byruntime/src/jobs.ts/scheduler.ts), there is no equivalent identity to fall back to foruserId— 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.getSignedUrlcalls issued to render a page) must omitownerUserIdentirely; 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 tohardDeleteUserStorageObjects(RFC 0033's cross-pluginowner_user_idsweep 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. asdk.portability.provideDeletehandler) 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" untilownerUserIdwas dropped from that oneput()call.A plugin's
schedules/jobs/eventshandler 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, andplugin-events.tseach generate one staticimport handlerN from "<path>"per manifest-declared handler intoruntime/generated/plugin-{schedules,jobs,events}.ts. All three previously computed<path>fromresolveComposeTargets(manifest)'stargets[0]— the plugin's composed app directory (runtime/app/(platform)/(plugins)/<id>/...or(minimal)/<id>/...), whichcompose-routes.ts'slinkOrCopyTargetmakes a realsymlinkSyncin production (isProd = NODE_ENV === 'production'), not a physical copy.runtime/tsconfig.json's ownexcludelist —app/(platform)/(plugins)/**,app/(minimal)/**— deliberately keeps composed plugin directories out of the project's normal type-check scope, butruntime/generated/*.tsis not excluded (it's covered by theincludeglobgenerated/**/*.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'simport 'nanoid') against the symlink's apparent location when the file is reached this way — walking up throughruntime/'s ownnode_modules— instead of following the symlink to its real target the way Next's own App Router route-file discovery does.runtime/package.jsondoesn't declare every package an arbitrary plugin might import (it does directly redeclaredrizzle-orm, since every plugin's schema/queries need it, but had never needed to redeclarenanoid), so resolution silently fails withTS2307: 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-ledgerwas the first plugin ever to combine a manifestschedulesdeclaration 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 anythingruntimedoesn't already have. Root-caused via an isolatedgit worktreereproduction of the exact failing CI build — deliberately not run against the live dev server sharing the actual working directory, and not run withNODE_ENV=productionthere either, both to avoid disrupting it — that reproduced the identical error byte-for-byte, then proved it wasn't a generalnanoid-resolution problem by deleting just the one plugin's import and watching every othernanoid-importing plugin (Kanban, Docs, Sheets — identical import pattern) compile successfully in the same build;tsc -p tsconfig.json --traceResolutionon 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 aruntime/package.jsonredeclaration 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 threecollectPlugin{Schedules,Jobs,Events}functions now import fromsrcFile(the plugin's real source path, already computed for the existence check just above) instead of acomposedFilebuilt fromtargets[0]. TheresolveComposeTargetscall itself stays — it still performs real validation (rejecting a multi-segmentroutePrefixon anoverlay-shell plugin) — only itstargets[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'srunMigrations()used to route the lock and unlock through two independentdbGet(pdb, ...)calls; sincepdb.db(Postgres) is backed by apg.Pool, each call is free to be served by a different physical connection, andpg_advisory_unlockonly 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 subsequentrunMigrations()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 viapg_stat_activity/pg_locksagainst 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()—$clientis drizzle-orm's own exposed rawPool, confirmed via its.d.ts) instead of letting the pool route each statement independently;migratePg/seedPlatformDatakeep 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 apg.Pool-backed Drizzle client without explicitly pinning a single checked-out client for the whole sequence — a baredb.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.tssuite run together, matching CI's one shared Postgresservices:container) surfaced two more, independent, pre-existing test-fixture bugs in the same area, fixed alongside it:migrate.pg.test.tsderivedmigrationsTablenames by concatenating a fixed prefix onto a full 36-char UUID-based schema name, silently overflowing Postgres's 63-byteNAMEDATALENidentifier limit into truncated, colliding names once several tests ran concurrently; andplugin-isolation-migration.pg.test.ts'safterEachran a blanketDROP SCHEMA "drizzle" CASCADE— the one fixed schema Postgres's migrator always tracks applied migrations in, regardless of any connection'ssearch_path— which raced and deleted other concurrently-running test files' own in-use migrations tables mid-run. Both are instances of the same underlying hazardmigrate.ts's own doc comment already flagged for one describe block in that file (a blanket schema-wideDROP/CASCADEagainst the shareddrizzleschema is never safe when other.pg.test.tsfiles 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 viapg_try_advisory_lockfrom a completely separate connection afterrunMigrations()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 theSOVEREIGN_ADMIN_KEYbearer token withcrypto.timingSafeEqualon 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 —checkAdminKeyis the single call point behind every/api/admin/*route (e.g. Console'sadminFetch, 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 returns429until 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, thex-sovereign-user-id/-roleheaders middleware injects elsewhere are never set or stripped here — a caller can forgex-sovereign-user-role: platform:ownerwithcurl -H, and three routes were found doing exactly that (fixed in0.94.15). The one route that cannot carry anAuthorizationheader (the email-template preview<iframe>) verifies the real session cookie in-route viaverifySessioninstead.
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.provideDeletehandlers are usually written as "delete everything scoped byowner_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 tonulland count those rows inDeletionResult.anonymized, never indeleted— a severed row still exists, anddeletedis the number an operator cites to evidence an erasure. Attribution columns are declared nullable; ownership columns (owner_user_id,user_id) stayNOT NULLand 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 becausesdk.directory.resolveUsers()returns nothing for it either way. The platform already answered this question the same way foractivity_log.actor_id(nullable, paired with anactor_typediscriminator). Relaxing an already-shippedNOT NULLcolumn is a plaindrizzle-kit generatein both dialects — the "hand-author the migration" rule covers renames, which drizzle-kit cannot do non-interactively, not nullability changes. - A plugin declaring
data:importmust registersdk.portability.provideImport(), anddata:exportmust be paired withprovideExport(). 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 in0.130.2(Warden had declareddata:importsince epic 22.5 with no handler). runtime/proxy.tsis Next'sproxyconvention (the renamedmiddleware, 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, noioredis, no DB access, sessions verified from the signed cookie cache with/api/verifyas 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 loadioredis(needs Nodenet/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
bigintcolumns andCOUNT(*)as JS strings from raw queries unless a global type parser is registered.row.started === nowcomparisons 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'suseIsMobile()returnsfalseon first paint and corrects in auseEffect; 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/consoleand Account's bare/accountboth 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 downstreamChildren.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 askeyon aFragmentthe client creates itself.0.114.2. - A spawned
gitnever inherits a repository from the environment. Git exportsGIT_DIR(and can exportGIT_WORK_TREE,GIT_INDEX_FILE, …) to the processes it spawns — hooks above all: apre-pushhook run from a linked worktree getsGIT_DIR=<main>/.git/worktrees/<name>. Code that addresses a repository bycwdon a fresh temp directory — the backup push inruntime/src/git-backup.ts, the pinned-ref clone inscripts/install-plugins.ts— then silently operates on the inherited repository:git initre-initializes it,add/commit/tagland 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 tocore.bare = true. Build the child environment withgitChildEnv()(stripsGIT_REPOSITORY_ENV_VARS), in tests too — the regression test ingit-backup.test.tssetsGIT_DIRto a throwaway repo and asserts it stays empty. - Credentials for a spawned
gitgo through the environment.GIT_ASKPASSscript for HTTPS tokens,GIT_SSH_COMMANDwith anIdentityFilewritten to a0600temp file and removed infinallyfor SSH keys — never argv, never an embedded URL, both of which are visible to every user on the host viaps.runtime/src/git-backup.tsis the reference implementation.0.124.0. packages/uicomponents are framework-agnostic. Nonext/navigationornext/linkimports; active state comes from the consumer (item.active) and links via an optionalrenderLink.NavTabsandNavListboth follow this; an epic task draft assumingNavListwould callusePathname()internally was corrected before it shipped.0.105.0.drizzle-kit generatecannot 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-updrizzle-kit generatethat reports "No schema changes".0.103.0.- Third-party plugin dependencies are never committed to
runtime/package.jsonorruntime/generated/plugin-deps.json.sovereign.plugins.jsonis gitignored per checkout, and hoisting (RFC 0057,bin/plugin-deps.ts) runs on the operator's own checkout viasv plugin add,scripts/install-plugins.ts, andpnpm dev's.localsync. 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. AgetPluginId(headers) ?? 'unknown'fallback laundered a missing identity into a no-op check insdk.notifications.send(); the check must fail closed.0.120.0.- Every outbound
fetchon a server render path has a timeout, and a route that blocks on network has aloading.tsx. An unbounded health probe left/wardenrendering nothing for the duration of a stalled upstream; with a 3s timeout plusloading.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 — userouter.refresh(). And a callback passed as auseEffectdependency 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 }vsIcon.module.css's.root { display: inline-block }is a tie decided by Next.js's per-route CSS bundle order, which differed between/console/groupsand/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
defineCommandargs have no repeatable type — it delegates tonode:util'sparseArgswithmultiplenever set, so a repeated flag silently keeps the last value. ReadrawArgsfor 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 throwsUnsafeMigrationError, whichapps/auth/src/migrate.ts'srunAuthMigrations()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 requiredaccount.issuercolumn (and keyed credential sign-in onissuer === 'local:credential'), then 1.7.3 removed it again per better-auth's own 1.7 upgrade guide;dependabot.ymlignores that window and the three packages have their own Dependabot group. Two genuinesession_datacookies captured from real 1.6.25 and 1.7.2 instances (runtime/src/__tests__/fixtures/) are permanent regression fixtures for the compact cookie-cache formatruntime/proxy.tsverifies 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.tsaliases everynext/*import toruntime/node_modules/next, and that alias is load-bearing. In a real build Next's own webpack config aliasesnextto the running package, sopackages/sdk'simport { headers } from 'next/headers'and the app share one module and one request-scopeAsyncLocalStorage. Vitest has no such alias by default: each importer resolvesnextthrough its ownnode_modulessymlink, and a pnpm lockfile that has splitnextinto several peer-suffix variants (the routine Dependabot batch #642 did, on an@types/node22-vs-26 suffix the tree already carried) hands runtime test files one physicalnext/headersandpackages/sdkanother.vi.mock('next/headers')patches the test file's copy; the SDK's realheaders()runs outside any request scope, throws Next's E251, thecatchtreats it as "no request", and everysdk.db/sdk.storage/sdk.envbackground-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
FileReadercompletion lands as an unhandled exception after the test returns and fails the whole run,0.94.14); scope a longtestTimeoutto the one slow test (0.94.13); a.pg.test.tsusing an isolated schema must mock modules that resolve the realgetPlatformDb()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).