> ## Documentation Index
> Fetch the complete documentation index at: https://praison.ai/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Persistence & Keys

> Chats persist as opaque strings; API keys live in the keychain, never in storage.

Two ports keep conversations and credentials apart on purpose.

```ts theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
// Explicit account — a second profile in the same slot is its own key
await secrets.set({ slot: "openai", account: "work" }, "sk-work");
await secrets.set({ slot: "openai", account: "personal" }, "sk-personal");

// The single-key case
const openaiKey = { slot: "openai" as const, account: "default" };
await secrets.set(openaiKey, "sk-...");
const configured = await secrets.has(openaiKey);
```

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph LR
    App[📱 App] --> Storage[💾 StoragePort]
    App --> Secrets[🔐 SecretsPort]
    Storage --> Disk[📄 Chats & settings]
    Secrets --> Keychain[🔑 Keychain / Keystore]

    classDef app fill:#8B0000,stroke:#7C90A0,color:#fff
    classDef port fill:#189AB4,stroke:#7C90A0,color:#fff
    classDef store fill:#10B981,stroke:#7C90A0,color:#fff

    class App app
    class Storage,Secrets port
    class Disk,Keychain store
```

## Quick Start

<Steps>
  <Step title="Read and write a chat">
    ```ts theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    await storage.write({ namespace: "chats", id: "c1" }, serialized);
    const raw = await storage.read({ namespace: "chats", id: "c1" });
    ```

    Every write is namespaced by construction; a bare string key is unrepresentable.
  </Step>

  <Step title="Store an API key">
    ```ts theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    await secrets.set({ slot: "openai", account: "default" }, "sk-...");
    ```

    A secret never passes through `StoragePort`.
  </Step>
</Steps>

***

## StoragePort

Persistence is opaque strings; serialisation lives in `core/src/chat/repository.ts`, not in the adapter.

| Member               | Behaviour                                                           |
| -------------------- | ------------------------------------------------------------------- |
| `read(key)`          | `null` for a missing key; only I/O failure is an error.             |
| `write(key, value)`  | Atomic — a concurrent read sees old or new, never a truncated file. |
| `remove(key)`        | Removing an absent key succeeds.                                    |
| `listIds(namespace)` | Ids in a namespace.                                                 |
| `clear(namespace)`   | Empty a namespace.                                                  |

Namespaces are a closed set: `chats`, `settings`, `drafts`, `cache`.

The storage contract is pinned by the same fixture — `storage_missing_is_undefined`, `storage_namespaces_collide`, and `storage_empty_is_absent` are three of the storage break modes. The last means a stored empty string is now guarded against hollowing, not just deletion: the *"an empty string is a value, not an absence"* case reddens by name if an adapter treats `""` as missing. See [Adapter Conformance](/docs/features/mobile/adapter-conformance).

### The atomic write path

`write(key, value)` is atomic because iOS kills a suspended app with no further callback — "interrupted halfway" is a routine Tuesday, not an exotic crash. The native store (`src-tauri/src/store.rs`, `FileStore::write`) satisfies the clause in **four steps, in order**, and every step closes a specific failure:

| Step                            | Call                                | Failure it closes if dropped                                                                                                                                                                                                                                             |
| ------------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 1. Write temp                   | `File::create(&temp)` + `write_all` | — the bytes land in a temp file next to the target, not over it.                                                                                                                                                                                                         |
| 2. `fsync` the temp file        | `file.sync_all()`                   | Without it, a power loss orders the rename ahead of the data — a correctly-named file full of zeroes, which reads as **corruption** rather than **absence**.                                                                                                             |
| 3. `rename` onto the final name | `fs::rename(&temp, &path)`          | Without it (a truncate-then-write in place, which `fs::write` and `tauri-plugin-store`'s `save()` both do), a kill mid-write loses the file. `rename(2)` is atomic within a directory: a concurrent reader sees the whole old file or the whole new one, never a prefix. |
| 4. `fsync` the directory        | `sync_dir(&dir)`                    | Without it, a power loss can leave the rename's metadata unwritten — the file exists but is not reachable through its name.                                                                                                                                              |

<Note>
  The two `fsync`s only matter across a power loss or kernel panic, so no in-process test can reach them; they are pinned on the **code** of `FileStore::write` by `tools/storage-seam.test.mjs` — *"the write path is write-temp-then-rename, with the data flushed first"* asserts the four calls appear **in order**. The atomicity behaviour itself is exercised by `a_concurrent_reader_never_sees_a_torn_value` (ten writers, one reader) and pinned as a contract break mode, `storage_torn_write`. The paired `a_write_interrupted_before_the_rename_leaves_the_old_value_intact` proves a leftover temp file is never listed as a chat.
</Note>

## Native store on Tauri

Up to this change there was one `StoragePort` implementation — the web adapter over `localStorage` — and `platform.ts` handed it to the Tauri build too. On iOS `localStorage` lives in a WebKit data store the system may **evict under storage pressure**; the user opens the app and their conversations are gone, with no error. Android's WebView survives eviction but is emptied by ordinary "clear cache". The Tauri build now gets its own durable, native store.

### Per-platform default

`storageFor(kind, bridge, view)` (`app/src/platform.ts`) picks the store from the detected platform kind — one named function, following `defaultEngineIdFor`'s precedent, so porting a new host adds one branch here rather than five:

| Platform kind                                           | Store                                                 | Backed by                                        |
| ------------------------------------------------------- | ----------------------------------------------------- | ------------------------------------------------ |
| `"tauri"` (iOS, Android, macOS, Windows, Linux desktop) | `createTauriStorage({ invoke: bridge.invokeStrict })` | Native filesystem under the app's data directory |
| `"web"`                                                 | `createWebStorage(view.localStorage)`                 | Browser `localStorage`, `praisonai.` prefix      |

```ts theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
// platform.ts — the native host is never offered localStorage as a fallback.
export function storageFor(kind, bridge, view): StoragePort {
  const web = createWebStorage(view.localStorage);
  if (kind !== "tauri") return web;
  const native = createTauriStorage({ invoke: bridge.invokeStrict });
  return preparedStorage(native, () => migrateStorage(web, native));
}
```

The registry ships **one** Storage; the native host is not offered `localStorage` as a fallback, so a bug in host detection cannot silently push the user back onto the evictable store. Conversely, the web build stays on `localStorage`, which is the only store a browser has.

### One file per key

The Rust store writes **one file per key** under the app's data directory, never one blob per store:

| Rule                                        | Behaviour                                                                                                                                                                                                                                                                                                    |
| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Root is the data directory                  | Layout is `<app_data_dir>/store/<namespace>/<encoded_id>`. Never `app_cache_dir()` — a cache directory is subject to OS reclamation, the very failure this store exists to close. Pinned by `"the store resolves its root under the app data directory"`, which also refuses `cache_dir()` and `temp_dir()`. |
| Ids are hex-escaped into one flat file name | `a-z0-9-_` stay literal; every other byte becomes `~XX`. So `../` cannot escape the data directory — `../../boot` becomes the file `~2e~2e~2f~2e~2e~2fboot` inside `chats/`.                                                                                                                                 |
| Uppercase is escaped too                    | iOS and macOS ship **case-insensitive** filesystems, so `Chat-A` and `chat-a` would be one file on a phone and two on a Linux CI box. Encoding uppercase makes the mapping injective everywhere. Pinned by `"ids_differing_only_in_case_are_different_chats"`.                                               |
| Namespaces are an allowlist                 | `["chats", "settings", "drafts", "cache"]` — the traversal defence for the directory component. An unknown namespace is **refused, never lazily created**. Pinned by `"an_unknown_namespace_is_refused_rather_than_created"`.                                                                                |
| Every command is registered                 | Each of the five storage commands is registered with Tauri in `src-tauri/src/lib.rs` via `generate_handler!`. Adding a sixth without registering it is a runtime failure the seam test catches: `"every command the adapter can send is registered with Tauri"`.                                             |

<Warning>
  **Why not `tauri-plugin-store`.** Its `save()` serialises the whole store and writes it with a plain `fs::write` — truncate, then write — so a kill mid-write loses not one chat but *all* of them. It is also one in-memory blob per file, so writing one chat rewrites every chat. One file per key plus temp-then-rename satisfies the atomicity clause in \~200 lines with no new dependency.
</Warning>

### One-time migration from `localStorage`

*Why the first launch on the new build does not look empty.* Every Tauri build so far wrote chats to `localStorage`. Switching to the native store without carrying that data would read an empty native store on the first launch — every existing conversation simply not there, which the user cannot tell apart from the eviction bug this change closes. Dev builds, TestFlight and sideloads all have real `localStorage` today, so "nobody has data" is a claim about other people's devices nobody here can check.

`migrateStorage(from, to)` (`adapters/src/storage/migrate.ts`) is a **one-time copy**, run before the first read:

| Rule                                     | Behaviour                                                                                                                                                                                                                 |
| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Runs once                                | The done-marker lives in the **target** store (`{ namespace: "cache", id: "migrated-from-web-storage" }`), not the source — the source is the thing that can disappear, and a marker written there would be lost with it. |
| Written last                             | The marker is written **after** the copy. A marker written first would, on a crash mid-copy, declare a half-finished migration complete and strand whatever had not been copied.                                          |
| Never overwrites                         | If the target already has a value for a key, the source value is not copied — a rollback that later wrote on the native side is not clobbered by a re-migration.                                                          |
| Never deletes the source                 | A rollback to a pre-migration build still finds its data in `localStorage`; an evictable copy is strictly better than a deleted one.                                                                                      |
| Reported failures do not brick the store | On a migration error, `preparedStorage` reports it via `onError` and the store is still usable — the marker is only written on success, so the next launch tries again.                                                   |
| Runs before the first read               | `preparedStorage` awaits the migration on the first storage call and memoises it, so ten concurrent boot reads run one migration; `detectPlatform` stays synchronous, so the first paint is not blocked.                  |

<Note>
  The negative + positive pair: `a failed migration leaves the new store usable` proves the store does not go down with a failed copy, and `an existing install's chats are carried onto the native store` proves the copy actually happens. Pinned in `app/src/platform.test.ts`, alongside `migration runs once and never overwrites newer data` and `preparedStorage runs its migration ONCE, before the first read`.
</Note>

### When storage is unavailable

The same two `StoragePort` throws — `SecurityError` (site data blocked) and `QuotaExceededError` (device out of room) — land in **two different places** depending on *when* they fire. At boot they are fatal; after boot on the `chats` route they are local.

| Trigger              | When it fires                                             |
| -------------------- | --------------------------------------------------------- |
| `SecurityError`      | Site data is blocked — a WKWebView with storage disabled. |
| `QuotaExceededError` | Storage pressure — the device is out of room to persist.  |

#### At boot

If `StoragePort` cannot be reached at all, boot fails to the crash screen instead of rendering an app that silently does nothing.

`createApp` calls `settingsStore.load()` unguarded, so either throw propagates. `mount()` wraps the whole boot in `bootOrFail`, which turns the throw into a typed `{ ok: false, reason: "storage_unavailable", detail }` and renders the crash screen with the real detail.

```ts theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
// main.ts — a throw from createApp becomes a typed BootResult.
async function bootOrFail(options) {
  try {
    return await createApp(options);
  } catch (error) {
    return { ok: false, reason: "storage_unavailable", detail: String(error) };
  }
}
```

<Warning>
  Before this, a `StoragePort` failure left a perfectly rendered app — top bar, composer, green Send button — in which nothing whatsoever happened, forever. See [Boot Failures](/docs/features/mobile/boot-failures) for every `BootResult.reason`.
</Warning>

#### After boot, on the `chats` route

The same two throws can be raised **after** boot, while `session.list()` / `listUnreadable()` are running to rebuild the chat list. Here they must **not** be fatal — the user already has a healthy conversation open. The route's rebuild block catches the rejection locally and renders a `role="alert"` warning row inside the failing `chats` section; the app-wide chrome and the open chat are untouched. See [History & Reopen → When the list load itself fails](/docs/features/mobile/history-and-reopen#when-the-list-load-itself-fails) for the UX detail. Pinned by `"a storage failure while the chat list loads stays LOCAL, not fatal"`.

<Warning>
  A floating rejection inside a post-boot async block reaches the global crash handler installed in step 0 of `mount()` and replaces the whole app with the fatal `storage_unavailable` screen. Any async work started in response to a route change must `.catch()` and render locally.
</Warning>

***

## SecretsPort

API keys go to the iOS keychain or Android keystore, never to `StoragePort`.

| Member             | Behaviour                                                                                  |
| ------------------ | ------------------------------------------------------------------------------------------ |
| `has(ref)`         | Presence only; must not fault the value into memory.                                       |
| `get(ref)`         | The full value, given to the engine only.                                                  |
| `set(ref, value)`  | Store a secret.                                                                            |
| `delete(ref)`      | Remove a secret.                                                                           |
| `isHardwareBacked` | `false` on the web adapter, where secrets are process memory; `true` on the Tauri adapter. |

The slot is a closed union — `openai`, `anthropic`, `google`, `openrouter`, `custom` — so a bug cannot write an attacker-influenced string into the keychain namespace.

<Warning>
  When `isHardwareBacked` is `false`, the settings view shows an explicit warning. A silent downgrade is how a user comes to believe a key is protected when it is not.
</Warning>

### Per-platform default

`secretsFor(kind, bridge)` (`app/src/platform.ts`) picks the store from the detected platform kind — the same shape as `storageFor`, so porting a new host adds one branch rather than five. On a device this is the platform keychain through `src-tauri/plugins/secrets`; in a browser it stays a module-scoped `Map`.

| Platform kind               | Store                            | Backed by                                                                                                                                |
| --------------------------- | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `"tauri"` on iOS / macOS    | `createTauriSecrets({ invoke })` | `SecItemAdd` / `SecItemCopyMatching` (`kSecClassGenericPassword`) — the value is held by the keychain daemon, nothing in the app sandbox |
| `"tauri"` on Android        | `createTauriSecrets({ invoke })` | `EncryptedSharedPreferences`: keys AES256-SIV, values AES256-GCM, master key from `AndroidKeyStore` (non-extractable)                    |
| `"tauri"` on any other host | **refuses**                      | nothing, ever                                                                                                                            |
| `"web"`                     | `createWebSecrets()`             | Module-scoped `Map` — lost on every reload                                                                                               |

```ts theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
// platform.ts — the web adapter is a Map, and is never a native fallback.
export function secretsFor(kind, bridge): SecretsPort {
  if (kind !== "tauri") return createWebSecrets();
  // invokeStrict: a rejecting keychain must not arrive as null (this port's
  // word for "never configured").
  return createTauriSecrets({ invoke: bridge.invokeStrict });
}
```

Unlike `storageFor`, this is **not** wrapped in a migration: the web adapter's `Map` dies with the process, so no install has a secret anywhere else to bring across — and a migration here would be a code path that reads secrets at boot.

### Why `isHardwareBacked` can be synchronous

`detectPlatform` is synchronous — `ShellPort.insets` has to be readable during the first paint — so the Tauri adapter cannot ask "am I on iOS?" before answering. The invariant is enforced one layer down instead: the Rust plugin **refuses** every call on a platform with no hardware store rather than falling back to a file, so *a secret stored through this adapter at all is in a hardware-backed store*. That is what makes `isHardwareBacked: true` honest without an async probe. Source: header comment of `adapters/src/tauri/secrets.ts`, and `secretsFor` in `app/src/platform.ts`.

<Warning>
  **Why not `tauri-plugin-stronghold` or the `keyring` crate.** Both were considered and rejected (source: comment block at the top of `src-tauri/plugins/secrets/src/lib.rs`):

  * **`tauri-plugin-stronghold`** is a password-encrypted **file**, not a hardware store — `isHardwareBacked` could not honestly become `true`, and the password has to be kept somewhere, which is the same problem one indirection down.
  * **The `keyring` crate** has macOS, Windows and Linux backends but **no Android backend** at all — half the platforms this app ships to.
  * **Encrypting into `store.rs`** would put the decryption key next to the ciphertext. That is obfuscation, not a keychain.
</Warning>

### The four Tauri commands and the closed union

The webview reaches the store through four commands, declared as `pub const`s in `src-tauri/src/secrets.rs` and matched on the TS side in `adapters/src/tauri/secrets.ts`.

| Command         | Body                        | Notes                                                                                                                                                  |
| --------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `secret_read`   | Returns the value or `null` | Called only when the engine needs the key; never on paint.                                                                                             |
| `secret_write`  | Writes a value              | An empty string is a stored value, not a delete.                                                                                                       |
| `secret_remove` | Removes                     | Deleting an absent secret succeeds.                                                                                                                    |
| `secret_has`    | Presence only               | Its **own** command — does NOT call `read()`. On Apple it uses a presence query with no `kSecReturnData`, so the value never crosses the FFI boundary. |

`has()` being its own command is rule 2 of the port: `has: (ref) => (await get(ref)) !== null` would pass every behavioural test and copy the user's API key into the webview heap on every settings repaint. Pinned by the seam `"asking whether a key is configured never sends the read command"` and `"a secret never reaches the plain storage port UNDER ANY KEY"`.

`SecretSlot` is repeated as a runtime allowlist in Rust (`SLOTS` in `secrets.rs`) — a TS-only union is erased at runtime, so the webview cannot smuggle an unlisted service name into the keychain. Pinned by `secrets::tests::a_slot_outside_the_union_is_refused_rather_than_named`.

<Note>
  **The web-adapter warning changed.** The old wording — *"Secrets are stored in app memory on this platform, not in a hardware-backed keychain"* — was shown on a phone AND in a browser, because `platform.ts` handed `createWebSecrets()` to both. Now a device gets a real keychain, so the message speaks only to browsers:

  > This browser has no keychain, so a key you enter is kept in this tab's memory only. It is not saved anywhere, and you will have to enter it again next time.

  Source: `SOFTWARE_SECRETS_WARNING` in `ui/src/settings/view-model.ts`. See [Settings Screen](/docs/features/mobile/settings-screen#the-software-secrets-warning).
</Note>

### How secrets are keyed

A secret is addressed by a `SecretRef = { slot, account }` pair — both halves are part of the key.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph LR
    Ref["🔐 SecretRef {slot, account}"] --> Key[🧩 composite key]
    Key --> Cred["🔑 one credential per (slot, account)"]

    classDef ref fill:#8B0000,stroke:#7C90A0,color:#fff
    classDef key fill:#189AB4,stroke:#7C90A0,color:#fff
    classDef cred fill:#10B981,stroke:#7C90A0,color:#fff

    class Ref ref
    class Key key
    class Cred cred
```

| Rule                       | Behaviour                                                                                                                                    |
| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| Two accounts, one slot     | Independent secrets. Writing one never touches the other; `has()` for a sibling account returns `false`; deleting one leaves siblings alone. |
| Two slots                  | Always independent.                                                                                                                          |
| Overwriting                | Replaces the value, never appends.                                                                                                           |
| Empty string `""`          | A **stored value**, not an absence — `has()` returns `true`, `get()` returns `""`. Adapters must not use `\|\|` to fall back on empty.       |
| Deleting an absent secret  | **Succeeds** (no throw). Callers need not wrap `delete` in `try`.                                                                            |
| Reading a never-set secret | Returns `null` — never `undefined`, never a throw.                                                                                           |

<Warning>
  Keying by `slot` alone makes a second profile silently overwrite the first: adding `account: "personal"` after `account: "work"` would share one credential, and deleting one would delete both. Both halves of the `SecretRef` are the key.
</Warning>

<Info>
  `SecretRef.slot` is a closed union — `"openai" | "anthropic" | "google" | "openrouter" | "custom"` (source: `core/src/ports/secrets.ts`). `account` is any string; `"default"` is the single-key case.
</Info>

### Writing a SecretsPort adapter

Any adapter that passes the runnable conformance contract is drop-in compatible with the fake and the web adapter.

```ts theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
import { describeSecretsContract } from "praisonai-mobile/adapters/conformance/secrets-contract";

describeSecretsContract("my adapter", () => createMyAdapter());
```

The contract lives at `adapters/src/conformance/secrets-contract.ts`. It exists because the web adapter had no test at all, and a mutation sweep found that collapsing its key from `${slot}:${account}` to `${slot}` survived the entire pre-PR suite.

The contracts themselves are pinned by a fixture that spawns them against a deliberately broken adapter — `secrets_slot_only` and `secrets_empty_is_absent` are the two secrets break modes. See [Adapter Conformance](/docs/features/mobile/adapter-conformance).

### How a setting reaches the keychain

A `secret`-flagged `SettingDef` names a keychain slot, and two helpers move a value in and out of it — the write half through the facade, the read half through the full port.

```ts theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
// core/src/settings/store.ts — the read-back half.
export async function readSecretSetting(
  secrets: SecretsPort,
  defs: readonly SettingDef[],
  key: string,
): Promise<string | null>;
```

`readSecretSetting` takes the full `SecretsPort` and **not** the `SettingsFacade`: the facade deliberately has no getter, so this function is unreachable from `ui/` with what `ui/` is handed — the type system says so rather than a comment. An empty stored value comes back as `null`, not `""`, because an empty string is what an `Authorization: Bearer ` header is built from before anyone notices.

`apiKeyFor(secrets, defs)` in `app/src/registry.ts` is the first non-test consumer of `SecretsPort.get` in the package — it calls `readSecretSetting(secrets, defs, "openaiApiKey")`.

```ts theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
// app/src/registry.ts — the openaiApiKey SettingDef.
{
  key: "openaiApiKey",
  default: "",
  label: "OpenAI API key",
  help: "Used by the in-process engine. Kept in the platform secret store, never in the settings file, and never shown back to you.",
  section: "Engine",
  secret: true,
  secretRef: { slot: "openai", account: "default" },
}
```

<Note>
  `default: ""` is required by `SettingDef.default`'s type but is never stored and never shown — a secret def has no value row at all, only a presence node. See [Settings Screen → Secret rows](/docs/features/mobile/settings-screen#secret-rows) and [API Keys](/docs/features/mobile/api-keys).
</Note>

***

## How Session Persistence Works

A completed turn is recorded through the session, which owns the join between the assistant-only run state and the two-sided stored chat. `end.userIndex` is produced by whatever actually did the write — `null` when the write failed.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
sequenceDiagram
    participant Turn
    participant Session
    participant Storage
    Turn->>Session: record(request, answer)
    Session->>Storage: write chat
    Storage-->>Session: ok / fail
    Session-->>Turn: indices | null
```

An unreadable chat is skipped rather than crashing the list, and the webview isolates storage to its own origin. The sequence writes the question and the answer in one call, which is why a completed turn survives a relaunch and an in-flight one does not — see [Chat Recovery → What a relaunch preserves](/docs/features/mobile/chat-recovery#what-a-relaunch-preserves).

<Note>
  The last-used engine is stored under the `engineId` key in the `settings` namespace and honoured on next launch, so the app reopens on the engine the user chose rather than a compiled-in default.
</Note>

### Web adapter backing store

The web adapter persists chats through `view.localStorage`, never `view.sessionStorage`.

`sessionStorage` dies with the tab, and on a phone the OS reclaims the webview routinely — every chat would die with every reclaim. `platform.ts`'s own comment is about `localStorage` **eviction** being the risk, so reaching for `sessionStorage` gets the failure mode backwards; the store it reaches for is now pinned by a contract test that fails if the adapter ever touches `view.sessionStorage`. The `StoragePort` API stays adapter-agnostic — only the web adapter's backing-store choice is pinned.

Every key the web adapter writes is prefixed with `praisonai.` (e.g. `praisonai.chats.c1`, not `chats.c1`). The prefix is what keeps the app's keys from colliding with anything else stored on the same origin — and losing it makes every existing install's data unreachable at the new key. The `StoragePort` contract cannot see this: it only checks read-back through the same adapter, which is self-consistent either way, so the prefix is pinned in the web adapter's contract test rather than in the port. Pinned by `"the web storage adapter namespaces its keys"` (regex: `/^praisonai\./`).

### How settings resolve

`isSet(key)` decides whether a stored value may outrank a caller's explicit argument, and an empty string never does.

If a settings key holds an empty string, the composition root's default wins. Boot no longer dies with `unknown_engine ''` on a blank field — a settings UI can persist `""`, where before a blank field outranked the default and bricked the app on next launch.

`isSet(key)` now returns `true` for keys the user changed inside the app, not only for keys loaded from disk on boot — so a setting changed at runtime is honoured by `chosenStringOr` in the same run. A refused write (validation failed) still leaves `isSet(key)` as `false`, so a rejected value cannot outrank a caller's argument.

`set` is `async` and **persist-before-mutate**: it writes through `StoragePort` before committing to memory. On a rejected persist — `SecurityError` (site data blocked) or `QuotaExceededError` (out of room) — the value and `chosen` roll back and `set` re-throws, so a failed disk write can never leave the store returning a value the next launch's `load()` will not read.

```ts theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
await settings.set("engineId", "");          // persists; the default still wins
await settings.set("engineId", "remote");    // isSet → true, chosen this run

// A refused persist rolls back and re-throws:
try {
  await settings.set("baseUrl", "http://10.0.0.7:9000");
} catch {
  // get("baseUrl") is what was stored BEFORE the failed write; isSet reverts too.
}
```

This rollback is what lets the Settings screen field reset to `settings.get(key) ?? def.default` after a failed write without lying to the user. See [Settings Screen](/docs/features/mobile/settings-screen) for the field-level view of this contract.

#### Loading a settings file the user can hand-edit

The settings file is on the user's disk. Anyone can open it in a text editor, so `load()` treats every field as untrusted and coerces on the way in:

Only `engineId` and `baseUrl` ship in `SETTING_DEFS` today, but the store's coercion and validation machinery stays intact for any future setting. Using a hypothetical `someNumberSetting` (default `0.7`, validator range-checked) to show each path:

| Disk value                   | What `load()` does                                                                                                                                                                                                                                               |
| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Non-scalar (object, array)   | Refused. The default from `SETTING_DEFS` is used and `isSet(key)` reports `false`. Without this, the settings screen renders the row as `[object Object]`. Pinned by `"a non-scalar value in the file is refused, not stored"`.                                  |
| Scalar the validator rejects | Refused. `someNumberSetting: "abc"` falls back to the default (`0.7`), and `isSet("someNumberSetting")` reports `false` — a rejected value is **not** the user's deliberate choice. Pinned by `"a value the validator rejects falls back to the default"`.       |
| Scalar the validator accepts | Kept. `someNumberSetting: 1.5` lands in the store and `isSet("someNumberSetting")` is `true`. This positive pair prevents a `load()` that stored nothing at all from passing the two rows above. Pinned by `"a value the validator ACCEPTS is kept — the pair"`. |

#### Clearing a secret wakes the screen too

`setSecret` notifies subscribers so the settings row can repaint the moment a key is stored — and `clearSecret` does the same. Without this parity, a user taps **Clear**, the key is gone from the keychain, and the row still reads `"configured"` until something else forces a redraw. Pinned by `"clearing a secret wakes the screen, exactly as storing one does"`. The shipping app now exercises this: pressing **Remove** on the OpenAI API key row fires `clearSecret`, which is `clearSecret`'s first non-test caller.

The public `SettingsFacade.isSet(key)` delegates to `store.isSet(key)` — it is never a constant. If it returned `true` for every key, every setting would look deliberately chosen, and a `SETTING_DEFS` default would then outrank the composition root's explicit engine argument in `chosenStringOr` — the exact override `isSet` exists to prevent. Pinned by `"the facade reports isSet from the store, not a constant"`.

`notify` iterates a **snapshot** of the subscriber set, not the live set. A subscriber that unsubscribes another one during its callback — a settings screen tearing down one row while a second row is still mounted — must not silence the second row's update. Iterating the live set would skip whichever entry follows a removal in iteration order. Pinned by `"a subscriber that unsubscribes another during notify does not silence it"`.

### Shipped defaults are valid

Every `SETTING_DEFS` entry with a `validate` function ships a `default` its own validator accepts unchanged.

`validate` runs on `set` and on `load` — **never on the shipped default itself** — so an out-of-range default is used verbatim on a fresh install and nothing anywhere complains until the value shows up in behaviour. A regression there fails silently: the user opens the app, the setting reads back its own broken default, and only the downstream feature (a decoder that refuses a temperature, a slider that snaps to a range) knows something is wrong.

The guard runs over the whole registry, not the one key that broke last time — the next drift will be a different key, and asserting `validate(def.default) === def.default` for every entry catches it before ship.

```ts theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
for (const def of SETTING_DEFS) {
  if (def.validate === undefined) continue;
  assert.deepEqual(def.validate(def.default), def.default);
}
```

Pinned by `"every setting's default satisfies its own validator"` in `app/src/registry.test.ts`.

***

## Best Practices

<AccordionGroup>
  <Accordion title="Route secrets through SecretsPort only">
    `settings.set()` refuses a secret-flagged key; `setSecret` is the only way in, and there is deliberately no getter in the UI facade. Persistence also strips any secret-flagged key from what it writes — `plainOnly` drops every def marked `secret: true` before the settings map reaches storage — so a bug that assigns a raw API key to a settings slot cannot end up on disk. The end-to-end guarantee — that no *public* route (a refused `set`, a `setSecret` that bypasses `StoragePort`, or a corrupted file that put a secret-flagged key on disk which `load` then drops) can land a secret in the plain-storage file — is pinned by `"no public route can put a secret into the persisted file"` in `core/src/settings/store.test.ts`, which drives all three routes and reads the persisted bytes back to prove they contain neither the refused `sk-live-must-not-leak` nor the disk-planted `sk-from-disk`, while ordinary settings still persist.
  </Accordion>

  <Accordion title="Keep serialisation in core">
    Adapters store opaque strings, so swapping the backing store — Tauri store, SQLite, OPFS — changes no format and loses no data.
  </Accordion>

  <Accordion title="Show the hardware-backing state honestly">
    Surface `isHardwareBacked` in settings so users know whether a key is keychain-protected or in memory.
  </Accordion>
</AccordionGroup>

***

## Related

<CardGroup cols={2}>
  <Card title="UI Shell Port" icon="mobile-button" href="/docs/features/mobile/shell-and-adapters">
    The adapters that back these ports.
  </Card>

  <Card title="Approvals & Cancellation" icon="hand-back-fist" href="/docs/features/mobile/approvals-and-cancellation">
    Human-in-the-loop on the device.
  </Card>

  <Card title="Boot Failures" icon="bug" href="/docs/features/mobile/boot-failures">
    The crash screen when storage is unavailable.
  </Card>

  <Card title="Chat Recovery" icon="database" href="/docs/features/mobile/chat-recovery">
    How a corrupt chat file is surfaced without hiding the rest.
  </Card>

  <Card title="Settings Screen" icon="sliders" href="/docs/features/mobile/settings-screen">
    The editable field that persists a change through this contract.
  </Card>

  <Card title="Native Secrets" icon="shield-keyhole" href="/docs/features/mobile/native-secrets">
    The keychain plugin behind `SecretsPort`, and why it refuses on an unsupported platform.
  </Card>
</CardGroup>
