<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: qnbs</title>
    <description>The latest articles on DEV Community by qnbs (@qnbs).</description>
    <link>https://dev.to/qnbs</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4141999%2Fe504de23-f790-452a-b4c0-6737de728058.png</url>
      <title>DEV Community: qnbs</title>
      <link>https://dev.to/qnbs</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/qnbs"/>
    <language>en</language>
    <item>
      <title>Preserve First: Recovery Design for Local Creative Data</title>
      <dc:creator>qnbs</dc:creator>
      <pubDate>Sun, 27 Sep 2026 23:47:22 +0000</pubDate>
      <link>https://dev.to/qnbs/preserve-first-recovery-design-for-local-creative-data-6o9</link>
      <guid>https://dev.to/qnbs/preserve-first-recovery-design-for-local-creative-data-6o9</guid>
      <description>&lt;p&gt;The most dangerous button in a creative tool is not "delete." It is the recovery dialog that appears when something is already wrong: &lt;em&gt;"We couldn't load your project — start fresh?"&lt;/em&gt; One click, and the file that failed to parse at 23:40 is gone at 23:41, together with the chapter it contained. The tool was trying to help.&lt;/p&gt;

&lt;p&gt;Local-first software cannot afford that. There is no server copy, no support ticket that restores yesterday's state, no account trash bin. When your app is the only place a manuscript exists, &lt;strong&gt;recovery code is the product's promise&lt;/strong&gt; — and the promise has to be: preservation is the default, destruction requires narrow, earned authority.&lt;/p&gt;

&lt;p&gt;This is how that rule is implemented in &lt;a href="https://github.com/qnbs/WorldScript-Studio" rel="noopener noreferrer"&gt;WorldScript Studio&lt;/a&gt;, an open-source writing studio that stores projects in the browser (IndexedDB) or on the desktop filesystem. Code references are from the repository at commit &lt;code&gt;2d9157c0&lt;/code&gt; (2026-09-28), release v1.28.8; simplified excerpts are labeled.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Refuse, don't improvise
&lt;/h2&gt;

&lt;p&gt;The canonical autosave path classifies what it finds on disk before writing anything. The classification set is small and explicit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// services/projectAutosaveCanonicalWriter.ts (excerpt)&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;CanonicalAutosaveRefusal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;MALFORMED&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;FUTURE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SUPPORTED_OLDER&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;UNSUPPORTED_OLDER&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;GENERATION_CONTRADICTION&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important part is what happens on anything that is not a clean current state — the header comment states it plainly: &lt;em&gt;"fail closed with a typed refusal; there is deliberately NO fallback to storageService.saveProject."&lt;/em&gt; No best-effort write over a file whose shape the app did not expect. A FUTURE record (written by a newer build) is not "fixed" by an older one. A GENERATION_CONTRADICTION is not smoothed over.&lt;/p&gt;

&lt;p&gt;And a refusal is not a silent no-op: it rejects the coordinator operation, so indexing, analytics, and the UI success state cannot claim durability that never happened. A refusal is a loud, typed, user-visible "I did not save, and here is why" — which is precisely what lets the user keep the still-open editor state and act, instead of discovering the loss tomorrow.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Destruction needs earned authority
&lt;/h2&gt;

&lt;p&gt;When startup itself fails, the recovery options are computed by one pure function — and its default answer to every destructive question is &lt;em&gt;no&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// services/startupRecoveryPolicy.ts (excerpt)&lt;/span&gt;
&lt;span class="c1"&gt;// a stored project the editor cannot load is kept as it is —&lt;/span&gt;
&lt;span class="c1"&gt;// reload only; no quarantine, no database reset.&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;PersistedProjectNotLoadableError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;failureKind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;project-corrupt&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;canQuarantine&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;canReset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;canSafeOpen&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The full matrix is deliberately narrow:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Quarantine&lt;/strong&gt; exists only for a &lt;em&gt;corrupt&lt;/em&gt; project on the &lt;em&gt;filesystem&lt;/em&gt; backend.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reset&lt;/strong&gt; exists only for a &lt;em&gt;storage-level&lt;/em&gt; failure on &lt;em&gt;IndexedDB&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Safe Open&lt;/strong&gt; exists only for a &lt;em&gt;refused&lt;/em&gt; desktop project (unsupported version, migration gap).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A project I/O error, an unavailable desktop storage authority, a corrupt in-browser record: none of them can escalate into quarantine or reset. The code comment frames the design rule directly — non-destructive failures never gain quarantine authority. Recoverability is not a mood the UI is in; it is a property of the failure kind, decided in one audited place.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Quarantine is a rename, not a delete
&lt;/h2&gt;

&lt;p&gt;When quarantine &lt;em&gt;is&lt;/em&gt; warranted, it moves the project directory into a quarantined-projects area — recoverable, inspectable, reversible. The interesting engineering is in what surrounds that rename:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Every destructive operation takes a project lock&lt;/strong&gt; — the same lock that fenced writes hold — so a passing check cannot be invalidated by a delete racing a save.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The lock file lives outside the project directory&lt;/strong&gt;, as a sibling. A lock inside the directory could be relocated by the very quarantine it is supposed to fence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A random per-creation token&lt;/strong&gt; sits next to &lt;code&gt;project.json&lt;/code&gt;. Delete and quarantine take it with the directory; every create writes a fresh one. A deleted-then-recreated project never matches an old window's token — even if the new &lt;code&gt;project.json&lt;/code&gt; is byte-identical to the old one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last mechanism exists because of a multi-window failure mode the code names explicitly: if window B deletes a project while window A still holds a loaded baseline, a save from A must not recreate it — &lt;em&gt;recreating it from the stale snapshot would resurrect deliberately removed data.&lt;/em&gt; Preservation has a second side: preserving means respecting destruction that already legitimately happened. The token is how the system tells "same project, still there" apart from "same bytes, different lifetime."&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The reset that fails closed
&lt;/h2&gt;

&lt;p&gt;The browser-side nuclear option — resetting IndexedDB — runs behind a gate with a generation/epoch invariant: a connection open that started before or during a reset can never proceed against the new database. Every registered connection closer must finish its teardown before the reset settles, including closers registered &lt;em&gt;while the drain is already running&lt;/em&gt;. And if any closer throws, the whole reset rejects — fail-closed, leaving the old state untouched rather than half-torn-down.&lt;/p&gt;

&lt;p&gt;Half-recovery is worse than no recovery. A reset that completes while one connection still holds the old store open has not recovered anything; it has created two truths.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The escape hatch the user owns
&lt;/h2&gt;

&lt;p&gt;All of the above protects data &lt;em&gt;inside&lt;/em&gt; the app's stores. The final layer gives the user a copy &lt;em&gt;outside&lt;/em&gt; of them: a full-library backup, aggregated into a single archive — a ZIP carrying a &lt;code&gt;vault.bin&lt;/code&gt; encrypted with AES-256-GCM, the key derived from a user passphrase via PBKDF2-HMAC-SHA-256 with 600,000 iterations (the OWASP 2024 minimum, as the code comment notes).&lt;/p&gt;

&lt;p&gt;Two properties matter here. First, the passphrase is the user's, not the platform's — the backup stays readable even if the app, the account that never existed, and the vendor all disappear. Second, encryption travels with the file: a backup emailed to yourself or dropped into a cloud folder does not become a plaintext copy of your manuscript.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist I would hand another team
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Type your refusals.&lt;/strong&gt; Every non-clean state gets a named classification and a loud stop — never a silent fallback write.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scope destructive authority by failure kind.&lt;/strong&gt; Corruption, I/O errors, and version gaps are different events; only one of them may even &lt;em&gt;offer&lt;/em&gt; quarantine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quarantine, don't delete.&lt;/strong&gt; Rename into recoverable space; fence with locks; detect resurrection across windows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fail closed on the nuclear path.&lt;/strong&gt; A reset that cannot fully drain must not half-execute.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Give the user an owned exit.&lt;/strong&gt; An encrypted, passphrase-held backup that outlives the app itself.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Then run the adversarial test: corrupt a project file by hand, open the app, and watch what it offers. If the first option destroys anything, the recovery design is not done.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Source note: WorldScript Studio is open source (&lt;a href="https://github.com/qnbs/WorldScript-Studio" rel="noopener noreferrer"&gt;github.com/qnbs/WorldScript-Studio&lt;/a&gt;). Code references correspond to &lt;code&gt;main&lt;/code&gt; at &lt;code&gt;2d9157c0&lt;/code&gt; (2026-09-28); release anchor v1.28.8. Key files: &lt;code&gt;services/projectAutosaveCanonicalWriter.ts&lt;/code&gt;, &lt;code&gt;services/startupRecoveryPolicy.ts&lt;/code&gt;, &lt;code&gt;services/fs/projectFsStore.ts&lt;/code&gt;, &lt;code&gt;services/storage/idbResetGate.ts&lt;/code&gt;, &lt;code&gt;services/libraryBackupService.ts&lt;/code&gt;. Part of the "Engineering WorldScript Studio" series.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;AI disclosure: AI tools helped with repository research, structure, and editing of this article. I reviewed the technical claims against the referenced source files before publication.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>reliability</category>
      <category>localfirst</category>
      <category>typescript</category>
    </item>
    <item>
      <title>One React/Vite product across PWA and Tauri—without pretending they are identical</title>
      <dc:creator>qnbs</dc:creator>
      <pubDate>Sun, 27 Sep 2026 23:32:35 +0000</pubDate>
      <link>https://dev.to/qnbs/one-reactvite-product-across-pwa-and-tauri-without-pretending-they-are-identical-17ld</link>
      <guid>https://dev.to/qnbs/one-reactvite-product-across-pwa-and-tauri-without-pretending-they-are-identical-17ld</guid>
      <description>&lt;p&gt;One React/Vite codebase can power a GitHub Pages site, an installed PWA, an edge-hosted deployment, and a Tauri desktop app.&lt;/p&gt;

&lt;p&gt;That does not make those surfaces interchangeable.&lt;/p&gt;

&lt;p&gt;This distinction matters whenever an application handles user projects, offline behavior, AI providers, filesystem access, or security policy. Shared components are valuable. Shared assumptions can be dangerous.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/qnbs/WorldScript-Studio" rel="noopener noreferrer"&gt;WorldScript Studio&lt;/a&gt; is a useful case study because its React/Vite application is intentionally available across browser/PWA and Tauri desktop environments. The product shares domain semantics, but it does not pretend that a browser tab and a native shell have the same authority boundaries. Code references are from the repository at commit &lt;code&gt;2d9157c0&lt;/code&gt; (2026-09-28), release v1.28.8; simplified excerpts are labeled.&lt;/p&gt;

&lt;h2&gt;
  
  
  Share the product model, not every implementation detail
&lt;/h2&gt;

&lt;p&gt;A cross-platform product needs a stable answer to questions such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What is a project?&lt;/li&gt;
&lt;li&gt;What data belongs in it?&lt;/li&gt;
&lt;li&gt;Which state is authoritative?&lt;/li&gt;
&lt;li&gt;What does export mean?&lt;/li&gt;
&lt;li&gt;What should happen when an AI provider is unavailable?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those answers should be shared.&lt;/p&gt;

&lt;p&gt;The mechanisms underneath them should not be forced to look identical.&lt;/p&gt;

&lt;p&gt;For a PWA, the browser provides IndexedDB, Cache Storage, service workers, Web APIs, WebGPU, and origin-scoped storage. A desktop shell can provide application-data filesystem access, native networking, OS integration, and platform packaging.&lt;/p&gt;

&lt;p&gt;Trying to hide every difference behind a single universal abstraction often creates a worse outcome: browser APIs leak into desktop code, native assumptions leak into web code, and the product accumulates several accidental definitions of persistence or network policy.&lt;/p&gt;

&lt;p&gt;A better rule is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Share domain semantics and interoperability contracts. Expose platform capabilities through explicit adapters.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The host changes the security boundary
&lt;/h2&gt;

&lt;p&gt;The same static build can be deployed to different hosts, but hosting changes what the application can guarantee.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Surface&lt;/th&gt;
&lt;th&gt;Important capability&lt;/th&gt;
&lt;th&gt;Important limitation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GitHub Pages&lt;/td&gt;
&lt;td&gt;Static public deployment&lt;/td&gt;
&lt;td&gt;Cannot inject arbitrary HTTP security headers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vercel / Cloudflare Pages&lt;/td&gt;
&lt;td&gt;Response headers and edge-function capabilities&lt;/td&gt;
&lt;td&gt;Still browser-origin storage for web project data&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Installed PWA&lt;/td&gt;
&lt;td&gt;Cached shell and browser-native installation&lt;/td&gt;
&lt;td&gt;Storage remains origin- and browser-specific&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tauri desktop&lt;/td&gt;
&lt;td&gt;Filesystem persistence and native HTTP&lt;/td&gt;
&lt;td&gt;Desktop project files are not currently app-encrypted at rest&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;GitHub Pages is a good example of why "deployed from the same source" is not enough.&lt;/p&gt;

&lt;p&gt;WorldScript uses a meta Content Security Policy there because GitHub Pages cannot add the corresponding response headers — the project's deployment documentation calls that meta tag the &lt;em&gt;sole enforcement point&lt;/em&gt; on that host. Vercel and Cloudflare Pages can set real response headers that mirror the meta CSP, and both can run a same-origin edge relay for supported functionality (a Claude proxy lives at &lt;code&gt;api/claude-proxy.ts&lt;/code&gt; for Vercel and &lt;code&gt;functions/api/claude-proxy.ts&lt;/code&gt; for Cloudflare, sharing one core module). Those are materially different deployment guarantees, even when users see the same React interface.&lt;/p&gt;

&lt;p&gt;The right documentation does not flatten that distinction. It names it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Storage is an authority decision, not a convenience API
&lt;/h2&gt;

&lt;p&gt;The PWA's live project path uses browser storage. The desktop path uses filesystem-backed stores under application data.&lt;/p&gt;

&lt;p&gt;Both are local. They are not the same.&lt;/p&gt;

&lt;p&gt;Browser persistence is governed by the browser's origin, quota, eviction behavior, and storage APIs. Desktop persistence is governed by filesystem access, native process boundaries, and the application's own read/write rules.&lt;/p&gt;

&lt;p&gt;That difference becomes especially important for security language. Browser/PWA protected IndexedDB data can use the application's passphrase-based encryption lifecycle when configured. The filesystem-backed desktop project store currently does not receive that same at-rest encryption.&lt;/p&gt;

&lt;p&gt;A UI toggle with the same name is not enough to make the protection equivalent. The actual persistence path decides what is protected.&lt;/p&gt;

&lt;h2&gt;
  
  
  Native networking changes what "local server" means
&lt;/h2&gt;

&lt;p&gt;A browser connecting to &lt;code&gt;localhost&lt;/code&gt; is still subject to browser rules such as CORS and Private Network Access. A Tauri desktop application can use an admitted native HTTP capability for local or cloud endpoints.&lt;/p&gt;

&lt;p&gt;That does not mean desktop networking is automatically safer. It means its policy must be defined and enforced differently — and "narrowly admitted" is meant literally here. The desktop shell's HTTP capability is an explicit allowlist, not an open pipe:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;src-tauri/capabilities/default.json&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;(excerpt)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"identifier"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http:default"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"allow"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"http://localhost:*/*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"http://127.0.0.1:*/*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"https://generativelanguage.googleapis.com/*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"https://api.openai.com/*"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;…remaining&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;provider&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;hosts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;nothing&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;else&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At runtime, the fetch adapter picks its implementation by environment: in the Tauri runtime it dynamically loads the native HTTP plugin; everywhere else it uses the browser's &lt;code&gt;fetch&lt;/code&gt;. For WorldScript, that desktop-native path supports local inference-server workflows such as Ollama-compatible endpoints without requiring the WebView to bypass browser-origin rules. The browser/PWA path should not silently probe local ports or pretend that the same route will work without user-managed server configuration.&lt;/p&gt;

&lt;p&gt;The general lesson is simple:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Browser security restrictions are product constraints, not annoyances to work around.&lt;/li&gt;
&lt;li&gt;Native capabilities should be narrowly admitted, not made globally available.&lt;/li&gt;
&lt;li&gt;UI copy must say when a feature is desktop-only or depends on local server configuration.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  PWA caching must not leak into desktop behavior
&lt;/h2&gt;

&lt;p&gt;A service worker is a powerful browser feature, but it is not a universal application runtime.&lt;/p&gt;

&lt;p&gt;WorldScript's service worker caches its web shell and handles offline fallbacks on web surfaces. In Tauri, the registration code takes the opposite path — it actively tears the browser mechanism down:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// register-sw.ts (excerpt — called only from the Tauri branch)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;teardownServiceWorkerInTauri&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;serviceWorker&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;registrations&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;serviceWorker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getRegistrations&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;registrations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;reg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;reg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unregister&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;keys&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;caches&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isWorldScriptOwnedCacheName&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;caches&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It unregisters any existing service worker &lt;em&gt;and&lt;/em&gt; purges the app's own caches. That prevents a stale browser cache from serving an offline fallback over the real bundled desktop application.&lt;/p&gt;

&lt;p&gt;This is a subtle example of platform adaptation done well: the feature is not merely disabled because it is inconvenient. It is disabled because its browser lifecycle would be the wrong authority for a native bundle.&lt;/p&gt;

&lt;h2&gt;
  
  
  A cross-platform checklist
&lt;/h2&gt;

&lt;p&gt;Before claiming that a web and desktop product are "the same app," ask:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Where does each platform persist the authoritative project?&lt;/li&gt;
&lt;li&gt;Which platform controls headers, CSP delivery, and network policy?&lt;/li&gt;
&lt;li&gt;Is offline behavior provided by a cached shell, a native bundle, or both?&lt;/li&gt;
&lt;li&gt;Which integrations need browser permissions, CORS configuration, or native capabilities?&lt;/li&gt;
&lt;li&gt;Does each storage path have the same encryption and recovery properties?&lt;/li&gt;
&lt;li&gt;Are platform-specific boundaries visible in the UI and documentation?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A shared codebase is an implementation advantage. It is not a reason to erase the differences users and maintainers need to understand.&lt;/p&gt;

&lt;p&gt;The goal is parity where the product contract is shared—and honesty where the platform changes the contract.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Source note: WorldScript Studio is open source (&lt;a href="https://github.com/qnbs/WorldScript-Studio" rel="noopener noreferrer"&gt;github.com/qnbs/WorldScript-Studio&lt;/a&gt;). Code references correspond to &lt;code&gt;main&lt;/code&gt; at &lt;code&gt;2d9157c0&lt;/code&gt; (2026-09-28); release anchor v1.28.8. Key files: &lt;code&gt;register-sw.ts&lt;/code&gt;, &lt;code&gt;index.html&lt;/code&gt;, &lt;code&gt;vercel.json&lt;/code&gt;, &lt;code&gt;docs/DEPLOYMENT.md&lt;/code&gt;, &lt;code&gt;src-tauri/capabilities/default.json&lt;/code&gt;, &lt;code&gt;services/ai/fetchAdapter.ts&lt;/code&gt;, &lt;code&gt;api/claude-proxy.ts&lt;/code&gt;, &lt;code&gt;functions/api/claude-proxy.ts&lt;/code&gt;. Part of the "Engineering WorldScript Studio" series.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;AI disclosure: AI tools helped with repository research, structure, and editing of this article. I reviewed the technical claims against the referenced source files before publication.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>react</category>
      <category>pwa</category>
      <category>tauri</category>
    </item>
    <item>
      <title>AI as an Optional Capability, Not an Application Dependency</title>
      <dc:creator>qnbs</dc:creator>
      <pubDate>Sun, 27 Sep 2026 22:49:19 +0000</pubDate>
      <link>https://dev.to/qnbs/ai-as-an-optional-capability-not-an-application-dependency-20jf</link>
      <guid>https://dev.to/qnbs/ai-as-an-optional-capability-not-an-application-dependency-20jf</guid>
      <description>&lt;p&gt;Try a small experiment with your favorite AI-powered app: revoke the API key, switch off the network, and open it again. A surprising number of products fail this test — not the AI features, the &lt;em&gt;product&lt;/em&gt;. Spinners that never resolve. A settings page that errors out. A startup sequence blocked on a model ping. Somewhere between the demo and the release, the AI integration stopped being a feature and became load-bearing infrastructure.&lt;/p&gt;

&lt;p&gt;WorldScript Studio is an open-source writing studio where AI assists with things like outlines, character work, and prose feedback. It is also, by design, fully usable with no key, no model, and no network. This article is about the mechanisms that keep it that way — a provider seam, policy gates in code, honest failure semantics, and a fallback layer that is allowed to say "no fallback exists." Code references are from the repository at commit &lt;code&gt;2d9157c0&lt;/code&gt; (2026-09-28), release v1.28.8; simplified excerpts are labeled.&lt;/p&gt;

&lt;h2&gt;
  
  
  Optionality is not a settings toggle
&lt;/h2&gt;

&lt;p&gt;Plenty of apps have an "AI: off" switch. Fewer have an architecture where off is a real, tested state. The difference shows up the first time a provider has an outage and your error handling turns out to be a toast notification saying "something went wrong" above a dead feature.&lt;/p&gt;

&lt;p&gt;I ended up with a rule: &lt;strong&gt;the AI layer may fail in every way it wants, as long as the failure is typed, explained, and contained.&lt;/strong&gt; Four mechanisms enforce it.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. One seam, not fifty call sites
&lt;/h2&gt;

&lt;p&gt;Every AI capability in the app — text generation, structured JSON, streaming, image generation — goes through one unified provider service. Providers sit behind it as adapters: Gemini, OpenAI, OpenRouter, Anthropic, and any OpenAI-compatible local server (Ollama, LM Studio) selected by base URL. A small factory normalizes them into a single &lt;code&gt;LanguageModel&lt;/code&gt; type from the Vercel AI SDK:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// services/ai/providerFactory.ts (excerpt, comments trimmed)&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;WorldScriptLanguageModelConfig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gemini&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;modelId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;openai&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;modelId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nl"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;openaiCompatible&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;baseURL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nl"&gt;modelId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;createLanguageModelForWorldScript&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;WorldScriptLanguageModelConfig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;LanguageModel&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* … */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The point of the seam is not abstraction for its own sake. It is that "the AI is down" has exactly one place to happen. Features never talk to a provider directly, so no feature can accidentally grow its own retry logic, its own key handling, or its own definition of what "offline" means.&lt;/p&gt;

&lt;p&gt;Bring-your-own-key lives behind the same seam: keys are stored encrypted (in the browser build, under a random non-extractable AES-256-GCM key in IndexedDB), and no provider SDK ever sees storage.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Policy gates that throw, not conventions that hope
&lt;/h2&gt;

&lt;p&gt;Routing is user-visible: four modes — &lt;code&gt;hybrid&lt;/code&gt;, &lt;code&gt;cloud&lt;/code&gt;, &lt;code&gt;local&lt;/code&gt;, &lt;code&gt;eco&lt;/code&gt; — with hybrid as the default. The part that matters architecturally is that the rules are enforced as code at the seam, not as conventions spread across components:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// services/ai/aiPolicy.ts (excerpt)&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;assertCloudAiAllowedSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AIProvider&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;privacy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PrivacySettings&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;LOCAL_INFERENCE_PROVIDERS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getActiveAiMode&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;local&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;eco&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Cloud provider blocked: AI mode is "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;" (local-only).`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;privacy&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;privacy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;localStorageOnly&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Cloud provider blocked: local-only mode is active.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// …&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A gate that throws is testable in a way a gate that "should be checked" is not — the policy test suite exercises the mode matrix directly. The same file carries a second hard gate: model training (LoRA fine-tuning) is restricted to local providers by allowlist, because training data is manuscript data, and manuscript data does not leave the device for that path. Note the scope of that sentence: it describes the training path, not a blanket privacy guarantee for every AI feature — a cloud provider you explicitly call necessarily receives the context you send it.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Failure semantics: retry what is retryable, explain what is doomed
&lt;/h2&gt;

&lt;p&gt;Provider errors are not one thing. A rate limit is not a wrong API key, and neither resembles "the laptop is offline." The seam classifies every failure into a small taxonomy:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;Retryable?&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;transient, rate limit, network&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;connection-class; a later attempt can succeed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;auth, policy, invalid request&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;deterministic — retrying repeats the failure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;offline&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;doomed until connectivity returns&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;canceled, permanent&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;user intent / unrecoverable&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each class carries a stable message key, so the UI can say "check your key" or "you are offline" instead of showing a generic error — and so the retry layer fails fast on doomed calls instead of backing off politely on a request that will never succeed. This is the difference between a degraded app and a lying app.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. A fallback layer that is allowed to say no
&lt;/h2&gt;

&lt;p&gt;When an AI call is terminally unavailable, some features can fall back to local heuristic generators — registered per task (an outline generator, a character-profile generator). The design decision I care about most is what the registry does when nothing is registered:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;runHeuristicFallback(task, ctx)
  → generator registered?  run it, return its result
  → nothing registered?    return null
                           caller keeps its existing behavior
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fallback layer is "always safe to ship empty," as the code comment puts it. &lt;code&gt;null&lt;/code&gt; means: no fallback exists, tell the user the feature needs a configured provider. What the registry never does is invent a plausible-looking answer to cover for a missing model. A fallback that quietly fabricates quality is worse than an honest refusal, because users cannot calibrate trust against output they cannot distinguish from the real thing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; feature call
      │
      ▼
 unified provider seam ──► policy gate (mode, privacy) ──throws──► typed error
      │                                                            │
      ▼                                                            ▼
 provider adapter (cloud/local)                          UI hint via message key
      │                                                            │
      ├─ terminal failure ──► heuristic fallback ──null──► honest refusal
      ▼
 streaming response
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Simplified: the real chain includes cancellation, request deduplication, and a provider fallback chain for transient cloud failures.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What "offline" honestly means here
&lt;/h2&gt;

&lt;p&gt;Precision matters more than marketing, so: with no network, the manuscript editor, planning tools, and storage work fully — they never touch the seam. Browser-local and Ollama-served models keep working if they were set up beforehand; the first model download obviously needs a connection, and local inference needs hardware that can carry it. Cloud providers are unreachable, and the offline error class says so. Hybrid mode is deliberately cloud-first; &lt;code&gt;local&lt;/code&gt; mode is the setting that guarantees the device never emits a request.&lt;/p&gt;

&lt;p&gt;AI being optional also means the app's identity does not collapse without it. There is no onboarding step that demands a key, no feature gate on the editor, no telemetry about your text leaving for a server you did not choose.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist I would hand another team
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;One seam.&lt;/strong&gt; All AI traffic through a single layer; providers as adapters behind it. If a feature imports a provider SDK directly, optionality is already gone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gates that throw.&lt;/strong&gt; Mode and privacy policy enforced in code at the seam, with tests — not documented conventions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Typed failure.&lt;/strong&gt; Classify errors by whether a retry can succeed; map every class to a user-actionable message.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refusal-capable fallback.&lt;/strong&gt; Degrade to simpler local behavior where it genuinely helps; return "no fallback" where it would only pretend.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Then run the unplug test in CI or by hand: no key, no network, cold start. Whatever still works is your product. Whatever breaks that should not have is your real dependency graph.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Source note: WorldScript Studio is open source (&lt;a href="https://github.com/qnbs/WorldScript-Studio" rel="noopener noreferrer"&gt;github.com/qnbs/WorldScript-Studio&lt;/a&gt;). Code references correspond to &lt;code&gt;main&lt;/code&gt; at &lt;code&gt;2d9157c0&lt;/code&gt; (2026-09-28); release anchor v1.28.8. Key files: &lt;code&gt;services/aiProviderService.ts&lt;/code&gt;, &lt;code&gt;services/ai/providerFactory.ts&lt;/code&gt;, &lt;code&gt;services/ai/aiPolicy.ts&lt;/code&gt;, &lt;code&gt;services/ai/aiErrorTaxonomy.ts&lt;/code&gt;, &lt;code&gt;services/ai/heuristicFallback/registry.ts&lt;/code&gt;. Part of the "Engineering WorldScript Studio" series.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;AI disclosure: AI tools helped with repository research, structure, and editing of this article. I reviewed the technical claims against the referenced source files before publication.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>architecture</category>
      <category>localfirst</category>
      <category>typescript</category>
    </item>
    <item>
      <title>Why a Writing Studio Can Work Without User Accounts</title>
      <dc:creator>qnbs</dc:creator>
      <pubDate>Sun, 27 Sep 2026 21:50:58 +0000</pubDate>
      <link>https://dev.to/qnbs/why-a-writing-studio-can-work-without-user-accounts-1l7j</link>
      <guid>https://dev.to/qnbs/why-a-writing-studio-can-work-without-user-accounts-1l7j</guid>
      <description>&lt;p&gt;Open your favorite writing tool and the first thing it asks for is an email address. That is not a UX quirk. The account is load-bearing: it anchors your identity, decides which copy of your manuscript is real, and gives someone else the job of recovering your work when things go wrong. When I started building WorldScript Studio, an open-source writing studio for long-form fiction, I wanted none of that. No account, no server-side manuscript database, no mandatory cloud anything.&lt;/p&gt;

&lt;p&gt;The interesting part is not the decision. It is the bill that arrives afterward. Once you delete the server, four things it used to do for free land back on your desk: identity, canonical truth, recovery, and trust. This article is about what replacing them actually looks like in a real codebase, including the parts that are still unfinished.&lt;/p&gt;

&lt;p&gt;Everything below references the repository state at commit &lt;code&gt;41e593f9&lt;/code&gt; (2026-09-27), release v1.28.8. Where I simplify code for readability, I say so.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "no account" actually removes
&lt;/h2&gt;

&lt;p&gt;It is tempting to frame local-first as a privacy feature. That framing is weak, because privacy is a property you have to keep proving, and because it undersells the architectural change. The account is not a login form. It is the answer to the question "whose copy wins?"&lt;/p&gt;

&lt;p&gt;With a server, the answer is boring: the server's copy wins. Your devices are caches with keyboards. Sync conflicts, corruption, migration, even the meaning of "saved" are all decided in one place you do not control but also do not have to build.&lt;/p&gt;

&lt;p&gt;Remove the server and every one of those questions becomes yours. Whose copy wins when the app crashed mid-save? What happens when a project file from a two-year-old version meets today's schema? Who is allowed to delete data, ever? These are not edge cases in a local-first app. They are the product.&lt;/p&gt;

&lt;p&gt;The design rule I ended up with: &lt;strong&gt;possession replaces identity.&lt;/strong&gt; There is nothing to log into, so the only meaningful identity is "the person sitting in front of this storage." That sounds obvious until you realize it inverts the threat model. You stop defending against strangers on the internet and start defending the user against the app itself: against silent overwrites, ambiguous recovery, and destructive "help."&lt;/p&gt;

&lt;h2&gt;
  
  
  Authority has to live somewhere
&lt;/h2&gt;

&lt;p&gt;WorldScript ships as a browser app (PWA) and as a Tauri desktop application from one codebase. The two do not have the same storage authority, and pretending they do is the first trap.&lt;/p&gt;

&lt;p&gt;In the browser, projects persist in IndexedDB. On the desktop, they persist as files through Tauri's filesystem layer. The selection happens once, at startup, and it is explicit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// services/storageService.ts (excerpt, comments trimmed)&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;StorageManager&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nx"&gt;backend&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;StorageBackend&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;            &lt;span class="c1"&gt;// defaults to dbService (IndexedDB)&lt;/span&gt;
  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nx"&gt;authorityFailure&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DesktopStorageAuthorityUnavailableError&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;initializeBackend&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;isTauriRuntime&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;fileSystemService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Desktop projects live on the filesystem only. If it cannot be opened,&lt;/span&gt;
        &lt;span class="c1"&gt;// startup stops visibly instead of continuing on a different store.&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;authorityFailure&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;DesktopStorageAuthorityUnavailableError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;backend&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fileSystemService&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;getBackend&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;StorageBackend&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ready&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;authorityFailure&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;authorityFailure&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;backend&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two decisions in that excerpt cost real thought.&lt;/p&gt;

&lt;p&gt;The first is fail-visible over fail-silent. An earlier version of this code effectively fell back to IndexedDB when desktop storage failed to open. That is the helpful-looking behavior that destroys trust: the app appears fine, but it is now showing you a different store with different data, and your "real" projects have vanished from view. The current behavior refuses to proceed. A user who sees an error can act on it; a user whose data silently teleported cannot.&lt;/p&gt;

&lt;p&gt;The second is that authority is queryable, not assumed. &lt;code&gt;getProjectAuthority()&lt;/code&gt; returns &lt;code&gt;'fs'&lt;/code&gt; or &lt;code&gt;'idb'&lt;/code&gt;, and recovery logic branches on the answer rather than on guesses about the runtime. This matters because the correct response to corruption differs per backend, as the next sections show.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                 ┌─────────────────────────────┐
                 │        React UI / views      │
                 └──────────────┬──────────────┘
                                │ reads/writes
                 ┌──────────────▼──────────────┐
                 │      StorageManager          │
                 │  getProjectAuthority()       │
                 └───────┬───────────────┬──────┘
            browser/PWA  │               │  Tauri desktop
                 ┌───────▼───────┐ ┌─────▼──────────┐
                 │   IndexedDB   │ │  filesystem    │
                 │  (dbService)  │ │  (fs stores)   │
                 └───────────────┘ └────────────────┘
                 one canonical authority per runtime — never both
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Writing is the dangerous part
&lt;/h2&gt;

&lt;p&gt;Reads are easy. The write path is where local-first apps lose data, usually politely.&lt;/p&gt;

&lt;p&gt;The naive autosave serializes the editor state and overwrites whatever is stored. That works until the stored record is newer than the app understands (a future version wrote it), older than the current schema, or malformed. "Just overwrite it" is a data-loss policy wearing a trench coat.&lt;/p&gt;

&lt;p&gt;The current autosave instead treats persistence as a reconciliation with a canonical authority. Roughly, the writer classifies the stored record and then chooses one of a few narrow paths:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; stored record state            autosave behavior
 ─────────────────────────────────────────────────────────
 ABSENT (first save)          → create a validated CURRENT record
 LEGACY_UNVERSIONED           → migrate, reload once, commit the
                                pending edit in the same call
 CURRENT                      → commit through the canonical bridge
 FUTURE                       → REFUSE (typed)
 SUPPORTED_OLDER /            → REFUSE (typed)
 UNSUPPORTED_OLDER
 MALFORMED /                  → REFUSE (typed)
 GENERATION_CONTRADICTION
 concurrent conflict          → stop; the next debounce cycle
                                re-evaluates with fresh state
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Simplified from &lt;code&gt;services/projectAutosaveCanonicalWriter.ts&lt;/code&gt;; the real result type carries reasons (&lt;code&gt;CanonicalAutosaveResult&lt;/code&gt;).&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The property I care about is the one that looks like a limitation: &lt;strong&gt;there is deliberately no fallback to the legacy save path.&lt;/strong&gt; If the stored state is something the writer cannot reconcile, the app refuses and tells the user, instead of doing something plausible. A refusal is annoying once. A silent downgrade of your manuscript is annoying forever, and you find out at the worst possible time.&lt;/p&gt;

&lt;p&gt;There is a concurrency budget too: at most one re-evaluation per autosave invocation. A state that would require a second reload ends as a typed conflict and defers to the next debounce cycle. This keeps the writer from ever looping against itself, at the cost of occasionally saving a few seconds later than theoretically possible. That trade is easy to accept and hard to notice, which is what good defaults look like.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recovery without a support team
&lt;/h2&gt;

&lt;p&gt;With an account, "something went wrong" is a support ticket. Without one, the recovery policy is the support team, so it has to be written down in code and it has to be conservative.&lt;/p&gt;

&lt;p&gt;The startup recovery policy classifies what failed before it decides what is allowed to happen:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// services/startupRecoveryPolicy.ts (excerpt, simplified for explanation)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;failureKind&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nx"&gt;projectLoadError&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;classification&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;UNSUPPORTED_OLDER&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;project-migration-gap&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;projectLoadError&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;reason&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;unsupported-version&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;     &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;project-unsupported&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;projectLoadError&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;reason&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;corrupt&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;                 &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;project-corrupt&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;projectLoadError&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;backend&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;filesystem&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;           &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;project-io&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;storage&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;failureKind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// quarantine only a corrupt desktop project; reset only an IndexedDB-level failure;&lt;/span&gt;
  &lt;span class="c1"&gt;// safe-open only a refused desktop project:&lt;/span&gt;
  &lt;span class="na"&gt;canQuarantine&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;failureKind&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;project-corrupt&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;backend&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;filesystem&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;canReset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;      &lt;span class="nx"&gt;failureKind&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;storage&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;        &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;backend&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;indexeddb&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;canSafeOpen&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="nx"&gt;projectLoadError&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;backend&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;filesystem&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
                 &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;failureKind&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;project-unsupported&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
                  &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;failureKind&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;project-migration-gap&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Simplified for explanation.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Read the three booleans as a philosophy. Quarantine moves a corrupt desktop project out of the active namespace; it never deletes, and it is never offered for a mere I/O hiccup, because a flaky disk is not corruption. Reset exists only for IndexedDB storage-level failure, the one case where the store itself, not a project, is broken. And a stored project the editor cannot load is kept exactly as it is: reload, nothing else.&lt;/p&gt;

&lt;p&gt;Even the one genuinely destructive operation, a full IndexedDB reset, is gated by a barrier (&lt;code&gt;idbResetGate.ts&lt;/code&gt;): every module that caches a database connection registers a closer, the reset drains all of them including closers registered mid-drain, and if any closer fails, the reset itself fails. The invariant is that a connection opened before or during a reset can never become authoritative afterward. Fail-closed applies to the app too, not just to users.&lt;/p&gt;

&lt;p&gt;None of this is clever. That is the point. Recovery policy is where you spend your complexity budget on being boring.&lt;/p&gt;

&lt;h2&gt;
  
  
  Secrets without an account
&lt;/h2&gt;

&lt;p&gt;"No account" does not mean "no secrets." Bring-your-own-key AI providers still need API keys stored somewhere, and "somewhere" needs an honest answer.&lt;/p&gt;

&lt;p&gt;In the browser build, provider keys are encrypted with AES-256-GCM under a random, non-extractable &lt;code&gt;CryptoKey&lt;/code&gt; held in IndexedDB (&lt;code&gt;services/storage/idbKeyStore.ts&lt;/code&gt;). Non-extractable is doing the real work: the key material cannot be read back out through the Web Crypto API, so casual exfiltration of the stored record does not yield a usable key. I am deliberately not claiming more than that. Browser storage is still browser storage; this raises the cost of theft, it does not make theft impossible.&lt;/p&gt;

&lt;p&gt;For moving data between machines, the export path is a user-invoked library backup: a ZIP containing metadata plus a &lt;code&gt;vault.bin&lt;/code&gt; encrypted with AES-256-GCM under a passphrase-derived key (PBKDF2-HMAC-SHA-256, 600k iterations). The weakness of that design is not cryptographic; it is the human passphrase. The UI says so, because a backup you cannot decrypt is a backup you do not have.&lt;/p&gt;

&lt;h2&gt;
  
  
  What still needs a network, said plainly
&lt;/h2&gt;

&lt;p&gt;Local-first marketing tends to mumble at this point, so let me be specific. WorldScript needs a network for: cloud AI providers (the request necessarily contains the context you send), the first download of a browser-local model, collaboration signaling, and a self-hosted LanguageTool server if you enable one. AI as a whole is optional infrastructure: the routing default is a hybrid mode, there is a heuristic fallback layer, and the manuscript editor is fully usable with AI off. The PWA shell caches for offline use via a service worker, so writing, planning, and project management survive a dead connection.&lt;/p&gt;

&lt;p&gt;What the app does not have: accounts, server-side manuscript storage, a cloud-sync product, or any telemetry pipeline for your content. A CRDT-based local-first sync layer is designed (ADR-0008 documents the Yjs migration as a gated, per-project, one-way door) and it is off by default. I mention it only to be clear about the boundary: designed and gated is not shipped, and this article does not claim it.&lt;/p&gt;

&lt;p&gt;Also not claimed: desktop project files are not application-encrypted at rest today. They are local files, with all the protection and exposure that implies. If that matters to you, OS-level disk encryption is the honest answer right now, and an app-level answer is on the roadmap rather than in the release.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist I wish someone had handed me
&lt;/h2&gt;

&lt;p&gt;Replacing the account meant replacing four things. If you are building local-first software, you will have to answer all of them, whether or not you write the answers down:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Identity → possession.&lt;/strong&gt; No login means the only identity is control of local storage. Your threat model shifts from strangers to your own failure modes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Server truth → an explicit local authority.&lt;/strong&gt; Pick one store per runtime, make the choice queryable, and fail visibly when it is unavailable. Never silently substitute a different store.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Support team → written recovery policy.&lt;/strong&gt; Classify the failure before choosing the remedy; scope destructive actions narrowly; make the dangerous paths fail closed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Trust us" → scoped, checkable claims.&lt;/strong&gt; Say which subsystem has which property. "Keys are encrypted in the browser build" is a claim you can defend. "Your data is safe" is a slogan.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The account was never the feature. It was an answer. Local-first means writing your own, and being precise about where yours still says "unsolved."&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Source note: WorldScript Studio is open source (&lt;a href="https://github.com/qnbs/WorldScript-Studio" rel="noopener noreferrer"&gt;github.com/qnbs/WorldScript-Studio&lt;/a&gt;). Code references correspond to &lt;code&gt;main&lt;/code&gt; at &lt;code&gt;41e593f9&lt;/code&gt; (2026-09-27); release anchor v1.28.8. Key files: &lt;code&gt;services/storageService.ts&lt;/code&gt;, &lt;code&gt;services/projectAutosaveCanonicalWriter.ts&lt;/code&gt;, &lt;code&gt;services/startupRecoveryPolicy.ts&lt;/code&gt;, &lt;code&gt;services/storage/idbResetGate.ts&lt;/code&gt;, &lt;code&gt;services/storage/idbKeyStore.ts&lt;/code&gt;, &lt;code&gt;docs/adr/0008-local-first-data-model.md&lt;/code&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;AI disclosure: AI tools helped with repository research, structure, and editing of this article. I reviewed the technical claims against the referenced source files before publication.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>localfirst</category>
      <category>architecture</category>
      <category>webdev</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
