Quick Start
1
Read and write a chat
2
Store an API key
StoragePort.StoragePort
Persistence is opaque strings; serialisation lives incore/src/chat/repository.ts, not in the adapter.
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:
The two
fsyncs 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.Native store on Tauri
Up to this change there was oneStoragePort 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:
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: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:
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.When storage is unavailable
The same twoStoragePort 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.
At boot
IfStoragePort 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.
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 toStoragePort.
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.
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 aspub consts in src-tauri/src/secrets.rs and matched on the TS side in adapters/src/tauri/secrets.ts.
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.
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.How secrets are keyed
A secret is addressed by aSecretRef = { slot, account } pair — both halves are part of the key.
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.Writing a SecretsPort adapter
Any adapter that passes the runnable conformance contract is drop-in compatible with the fake and the web adapter.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
Asecret-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.
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").
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 and API Keys.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.
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.Web adapter backing store
The web adapter persists chats throughview.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.
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, soload() 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:
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
EverySETTING_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.
"every setting's default satisfies its own validator" in app/src/registry.test.ts.
Best Practices
Route secrets through SecretsPort only
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
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
Show the hardware-backing state honestly
Surface
isHardwareBacked in settings so users know whether a key is keychain-protected or in memory.Related
UI Shell Port
The adapters that back these ports.
Approvals & Cancellation
Human-in-the-loop on the device.
Boot Failures
The crash screen when storage is unavailable.
Chat Recovery
How a corrupt chat file is surfaced without hiding the rest.
Settings Screen
The editable field that persists a change through this contract.
Native Secrets
The keychain plugin behind
SecretsPort, and why it refuses on an unsupported platform.
