> ## 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"}}
import { OPENAI_KEY } from "praisonai-mobile/app/registry";

await secrets.set(OPENAI_KEY, "sk-...");
const configured = await secrets.has(OPENAI_KEY);
```

```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`.

<Note>
  Writes are atomic because iOS can kill a suspended app mid-write. A halfway write is a normal occurrence on mobile, not a crash scenario.
</Note>

***

## 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. |

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>

***

## 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.

<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>

### 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.

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

***

## 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.
  </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>
</CardGroup>
