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.
// 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);Quick Start
- Read and write a chat
ts 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.
- Store an API key
ts await secrets.set({ slot: "openai", account: "default" }, "sk-...");A secret never passes through
StoragePort.
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.
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. |
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 |
// 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". |
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. |
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.
// 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) };
}
}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 for the UX detail. Pinned by "a storage failure while the chat list loads stays LOCAL, not fatal".
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.
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 |
// 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.
The four Tauri commands and the closed union
The webview reaches the store through four commands, declared as pub consts 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.
How secrets are keyed
A secret is addressed by a SecretRef = { slot, account } pair — both halves are part of the key.
| 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. |
Writing a SecretsPort adapter
Any adapter that passes the runnable conformance contract is drop-in compatible with the fake and the web adapter.
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.
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.
// 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").
// 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" },
}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.
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.
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.
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 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.
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
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.
Keep serialisation in core
Adapters store opaque strings, so swapping the backing store — Tauri store, SQLite, OPFS — changes no format and loses no data.
Show the hardware-backing state honestly
Surface isHardwareBacked in settings so users know whether a key is keychain-protected or in memory.
Related
The adapters that back these ports.
Approvals & CancellationHuman-in-the-loop on the device.
Boot FailuresThe crash screen when storage is unavailable.
Chat RecoveryHow a corrupt chat file is surfaced without hiding the rest.
Settings ScreenThe editable field that persists a change through this contract.
Native SecretsThe keychain plugin behind SecretsPort, and why it refuses on an unsupported platform.