Vault
Store, read, organize and sync your app's encrypted records with VaultStore.
Preview
This page describes a pre-release version of the SDK. Names, versions and APIs on this page can change before the release.
The vault is the encrypted record store of the current account. Your app keeps its own JSON documents in it, groups them in folders, attaches per-user settings to them and shares them with other accounts. The SDK encrypts every record on the device, keeps a local copy and syncs it with Folio.
You work with the vault through one store, VaultStore: you mount it on the runtime, dispatch
VaultAction values and read the result from its state, VaultUiModel. The runtime itself returns
no vault data. See Runtime for how stores work in general.
Records belong to your organization
The vault of a runtime holds the records of one organization: the organization value of the
configuration. Your app never writes the organization into a record. Every
record carries a schema, a short name your app chooses for the kind of document, and the SDK
qualifies it with the organization internally.
| Value | Rule |
|---|---|
organization | Not empty; lowercase letters a-z, digits 0-9 and -. An invalid value fails FolioSdk.create. |
schema | A lowercase letter followed by up to 31 characters of a-z, 0-9 and _. |
A write with a schema name that breaks the rule fails with UnknownSchema.
The schema digital holds the documents organizations issue to the person. The SDK receives them
into the vault on its own, and FolioDocumentListStore and FolioDocumentStore read them; see
Folio documents. Do not write your own records under digital: the
document stores leave out a record they cannot read as an issued document.
Mount the store
Mount VaultStore on the runtime after FolioSdk.create has returned, and close it when your app
no longer needs it. mount takes any VaultHost; a FolioSdk runtime is one.
let vault = try VaultStore.mount(runtime: sdk)
let records = vault.state.records
vault.close()The state updates on its own whenever the vault changes: after your own writes, after a sync, and
when the user signs in or out. On the web it also follows writes that another tab makes to the same
account's vault. Observe it the way you observe every store: on iOS VaultStore is an
ObservableObject whose state is @Published, on Android state is a StateFlow<VaultUiModel>,
and on the web subscribe(listener) calls the listener on every change and returns the function
that stops it. On every platform, subscribe() without arguments returns a VaultSubscription
whose async next() yields the current state first, then the latest state after each change
(intermediate states can be skipped), and ends after the store is closed or the runtime shuts down.
See Runtime.
Mount it from the UI
In Compose and React, the SDK mounts the store for you inside a FolioSDKProvider, and closes it
when the component leaves.
On iOS, pass the mounted store to a SwiftUI view as an observed object.
struct RecordList: View {
@ObservedObject var vault: VaultStore
var body: some View {
List(vault.state.records, id: \.header.id) { record in
Text(record.header.schema)
}
}
}Client-shaped bodies
The vault stores your documents as they are. There is no typed layer over them: a document is a JSON string that your app encodes and decodes itself.
RecordContent
The body of a record, for create and update.
| Field | Type | Meaning |
|---|---|---|
schema | String | Your name for the kind of document, without the organization. |
version | u32 | Your version of the document format. The SDK stores it next to the payload. |
payload | String | Your JSON document. It must be valid JSON; the SDK returns it as written, without whitespace around the value. |
version is a UInt32 on iOS, a Long on Android and a number on the web. A payload that is
not valid JSON fails the write with Codec.
FolderContent
The body of a folder. It has the same fields as RecordContent: schema, version and a JSON
payload, for example the folder's name in a shape your app chooses.
DocumentSettings
Per-user settings of a record or a folder, such as a favorite flag or a custom label.
| Field | Type | Meaning |
|---|---|---|
version | u32 | Your version of the settings format. |
payload | String | Your JSON document with the settings. |
Settings belong to the account that writes them: every account that can see a record keeps its own settings for it, and nobody else sees them. An account that only views a shared record can still write its own settings for it.
Encode and decode the payload
Encode your document to a JSON string when you write, and decode payload when you read.
struct Note: Codable {
let title: String
let body: String
}
let note = Note(title: "Visa", body: "Renew")
let payload = String(decoding: try JSONEncoder().encode(note), as: UTF8.self)
let content = RecordContent(schema: "note", version: 1, payload: payload)
let notes = try vault.state.records
.filter { $0.header.schema == "note" }
.map { try JSONDecoder().decode(Note.self, from: Data($0.payload.utf8)) }Check header.schema and header.version before you decode a payload: the vault can hold
documents of several schemas and of older versions of your format.
Actions
VaultAction has four cases.
| Action | Payload | Effect |
|---|---|---|
Sync | none | Pushes local changes, then fetches remote changes. |
Write | VaultWrite | Creates, changes or removes a record or a folder. See below. |
Share | SharingRequest | Creates, lists, accepts or revokes invites. See Sharing. |
DismissError | none | Clears the error of the last failed action from the state. |
On iOS the cases are .sync, .write(value:), .share(value:) and .dismissError; on Android
VaultAction.Sync, VaultAction.Write, VaultAction.Share and VaultAction.DismissError; on the
web VaultAction.sync, VaultAction.write(...), VaultAction.share(...) and
VaultAction.dismissError.
try vault.dispatch(action: .write(value: .create(record: content)))
try vault.dispatch(action: .sync)
try vault.dispatch(action: .dismissError)A dispatched action runs in the background. busy is true while at least one Sync, Write or
Share action is running. When it finishes, a failure lands in error, and a success clears
error. The records themselves reach the state through the vault's own change notifications, so
read them from records and folders, not from the action.
VaultWrite
| Write | Fields | Effect |
|---|---|---|
Create | record: RecordContent | Creates a record owned by the signed-in account. |
Update | id: String, record: RecordContent | Replaces the body of a record. |
Delete | id: String | Moves an active record to the deleted state. |
Restore | id: String | Moves a deleted record back to the active state. |
PermanentlyDelete | id: String | Removes a deleted record for good. |
UpdateSettings | id: String, settings: DocumentSettings | Replaces the signed-in account's settings of a record. |
CreateFolder | folder: FolderContent, open: Bool | Creates a folder. open is the folder's initial open flag. |
UpdateFolder | id: String, folder: FolderContent | Replaces the body of a folder. |
SetFolderOpen | id: String, open: Bool | Sets the folder's open flag. |
DeleteFolder | id: String | Removes a folder for good. Folders have no deleted state. |
AddToFolder | folderId: String, recordId: String | Puts a record into a folder. |
RemoveFromFolder | folderId: String, recordId: String | Takes a record out of a folder. |
UpdateFolderSettings | id: String, settings: DocumentSettings | Replaces the signed-in account's settings of a folder. |
ResolveConflict | id: String, resolution: ConflictResolution | Settles a sync conflict of a record. See Conflicts. |
The fields keep these names and this order on every platform. On iOS each case is lowerCamelCase
with labeled fields, such as .addToFolder(folderId:recordId:); on Android it is a class of
VaultWrite, such as VaultWrite.AddToFolder(folderId, recordId); on the web it is a function of
VaultWrite, such as VaultWrite.addToFolder(folderId, recordId).
Rules the vault enforces:
- Only the owner changes a record.
Update,Delete,RestoreandPermanentlyDeletefail withPermissionDeniedon a record whosepermissionisView.Updatealso fails withPermissionDeniedon a record that is not active. - A record keeps its schema.
Updatewith a differentschemafails withSchemaMismatch. - Lifecycle moves one step at a time. Active to deleted (
Delete), deleted to active (Restore), deleted to permanently deleted (PermanentlyDelete). Any other move fails withInvalidRequest. - Folder changes need a connection in a signed-in session. In a signed-in session,
CreateFolder,UpdateFolder,SetFolderOpen,DeleteFolder,AddToFolderandRemoveFromFolderfail withOfflineFolderOpNotSupportedwhile the device is offline, and withUpstreamUnavailablewhen the network status cannot be read. In a transient session they work offline. - A record joins a folder after both reached Folio. In a signed-in session,
AddToFolderfails withInvalidRequestwhile the folder or the record has not been synced yet. Sync, then retry.AddToFolderof a permanently deleted record fails withInvalidRequest. - A pending settings change blocks a body change. While a settings change of a record has not
reached Folio yet,
Update,Delete,RestoreandPermanentlyDeleteof that record fail withRevisionConflict. Sync, then retry.
The vault store has no action that adds attachments to a record or reads attachment bytes. The
inquiry attaches the verification artifacts to the identity records it
saves; header.attachments lists their ids. The images of an issued document are readable through
FolioDocumentPhoto.image; see Photos.
State
VaultUiModel is the state of VaultStore.
| Field | Type | Meaning |
|---|---|---|
available | Bool | true while the current session holds a vault. false while no session holds one; the lists are then empty. |
records | [VaultRecord] | Every record of the organization that the account can see, sorted by id. |
folders | [Folder] | Every folder of the account. |
sync | DocSyncStatus | The sync status of the vault. |
sharing | SharingView | The results of Share actions. See Sharing. |
loading | Bool | true until the store has read the vault for the first time. |
busy | Bool | true while a Sync, Write or Share action runs. |
error | VaultErrorView (optional) | The error of the last failed action, or the error of reading the vault. |
VaultRecord
| Field | Type | Meaning |
|---|---|---|
header | RecordHeader | Identity, ownership, lifecycle and sync flags. |
metadata | RecordMetadata | The account's settings and the folders the record is in. |
payload | String | Your JSON document as your app wrote it, without whitespace around the value. |
RecordHeader
| Field | Type | Meaning |
|---|---|---|
id | String | The record id. Use it in Update, Delete and every other action. |
schema | String | Your schema name, without the organization. |
version | u32 | The version your app wrote with the body. |
ownerId | String | The account that owns the record. |
permission | Permission | Owner for the account's own records, View for records shared with it. |
state | RecordState | Active, Deleted or PermanentlyDeleted. |
createdAt | i64 (optional) | Creation time, milliseconds since the Unix epoch. Absent when unknown. |
updatedAt | i64 (optional) | Time of the last change, milliseconds since the Unix epoch. Absent when unknown. |
revokedAt | i64 (optional) | When the organization that issued the record revoked it, milliseconds since the Unix epoch, as Folio last reported it. Absent while the record is not revoked, and for a record Folio has not synced yet. |
attachments | [String] | Ids of the files attached to the record. |
sync | RecordSync | The record's sync flags. |
The SDK assigns an id when your app creates the record. Once Folio has confirmed the record,
id is the id Folio assigned, so read the id from the state again after a sync instead of keeping
it. Actions on the device that created the record accept both ids, so an action your app
dispatches with the earlier id still finds the record. Ids the SDK assigns start with D_ for
documents, F_ for folders and AT_ for attachments.
A deleted record stays in records with state Deleted until it is restored or permanently
deleted. The SDK permanently deletes a record the account owns on its own, during its background
sync, once the record has been deleted for 60 days. Filter on state to show active records and a
trash view.
Revocation does not remove a record: the access the account already accepted and the record's
content stay available. Use revokedAt to show that the issuer revoked it. For an issued document,
FolioDocument.status is then Revoked; see Status.
let active = vault.state.records.filter { $0.header.state == .active }
let trash = vault.state.records.filter { $0.header.state == .deleted }
let shared = vault.state.records.filter { $0.header.permission == .view }RecordMetadata
| Field | Type | Meaning |
|---|---|---|
settings | DocumentSettings (optional) | The signed-in account's settings, when it wrote any. |
folders | [String] | Ids of the folders the record is in. |
folders follows the folder membership on the device, so it agrees with FolderMetadata.records,
also before a change has reached Folio.
RecordSync
| Field | Type | Meaning |
|---|---|---|
conflict | ConflictState | None, or the kind of conflict the record is in. |
dirty | Bool | true while the record has local changes that Folio has not seen. |
Folder
| Field | Type | Meaning |
|---|---|---|
header | RecordHeader | The same header as a record. |
metadata | FolderMetadata | Settings, members and the open flag. |
payload | String | Your JSON folder document. |
FolderMetadata
| Field | Type | Meaning |
|---|---|---|
settings | DocumentSettings (optional) | The signed-in account's settings of the folder. |
records | [String] | Ids of the records in the folder, sorted. |
open | Bool | The folder's open flag. |
Sync
Every write lands in the local vault first and shows up in the state at once, with
header.sync.dirty set. In a signed-in session the SDK syncs in the background:
- when the session starts, it pushes local changes and fetches remote changes;
- after every local write, it pushes;
- when Folio reports a change, it fetches the changed record or the remote changes;
- when the network comes back, it pushes and fetches;
- when the app returns to the foreground, it pushes, and fetches at most once a minute;
- when a background push fails, it tries again after 2 seconds, then after a delay that doubles each time, up to 5 minutes.
Sync runs a push and a fetch right away. A failed Sync action is not retried.
In a transient session, before the user signs in, the SDK pushes nothing: local changes stay on the
device. It still fetches remote changes in the background, at the same moments as in a signed-in
session. Sync, ResolveConflict and every Share action fail there with
TransientNotSupported. When the user signs in, the SDK copies the records and folders of the
transient vault, with their attachments, into the account's vault, and removes the transient vault
once every copy is in place.
DocSyncStatus
| Field | Type | Meaning |
|---|---|---|
phase | DocSyncPhase | What the sync is doing. |
pendingPushCount | u32 | Local changes waiting to be pushed. |
conflictCount | u32 | Records in a conflict. |
recordFailureCount | u32 | Records whose local changes the last push could not send. |
attachmentFailureCount | u32 | Attachments the last fetch could not download or clean up, plus local attachment files a later write, such as PermanentlyDelete, could not remove. |
lastSuccessfulSyncAtMillis | i64 (optional) | Time of the last successful fetch, milliseconds since the Unix epoch. |
isSyncing | Bool | true while phase is Syncing. |
DocSyncPhase
| Phase | Payload | Meaning |
|---|---|---|
Idle | none | No sync is running. |
Syncing | none | A sync is running. |
Degraded | message: String | Some records or attachments failed in the last sync or in a later local cleanup. |
Error | message: String | The last sync failed, or the vault on the device could not be read. |
The Degraded message counts the failures, as in 1 record failure(s), 0 attachment failure(s).
When a record stored on the device cannot be decoded or applied at the start of a session, phase
is Error with that error's message. The SDK keeps the stored copy as it is and fetches no remote
changes until a later session reads the records: Sync fails with that error in the meantime.
switch vault.state.sync.phase {
case .idle, .syncing:
hideSyncWarning()
case .degraded(let message), .error(let message):
showSyncWarning(message)
}Conflicts
A record enters a conflict when a local change of it cannot be pushed as it is. The record's
header.sync.conflict names the kind, and sync.conflictCount counts such records.
ConflictState | Meaning |
|---|---|
None | No conflict. |
RemoteUpdated | The record changed on Folio after your local change was made. |
RemoteDeleted | The record was deleted on Folio. |
RemoteRevoked | The account lost access to the record. |
WriteRejected | Folio rejected the local change. |
Settle a conflict with VaultWrite.ResolveConflict and a ConflictResolution:
| Resolution | Effect |
|---|---|
KeepLocal | Pushes the local version over the remote one. Only for RemoteUpdated; any other conflict fails with InvalidRequest. |
AcceptRemote | Drops the local change. For RemoteDeleted and RemoteRevoked the record leaves the vault; otherwise the record takes the remote version. |
ResolveConflict on a record without a conflict fails with InvalidRequest.
if record.header.sync.conflict != .none {
try vault.dispatch(
action: .write(value: .resolveConflict(id: record.header.id, resolution: .acceptRemote))
)
}Errors
error in the state is a VaultErrorView:
| Field | Type | Meaning |
|---|---|---|
errorCode | String | The code of the case: vault. and the case name in lowerCamelCase, such as vault.notFound. |
details | VaultError | The error itself. Match on its case to react to it; the cases and their payloads are in the table below. |
DismissError clears the error of the last failed action. An error from reading the vault stays
until the next successful read.
| Error | Payload | When |
|---|---|---|
NotFound | message | No record or folder has the given id. |
PermissionDenied | none | The account is not allowed to change the record. |
Revoked | none | The account's access to the record was revoked. |
PolicyDenied | none | Folio's policy refused the request. |
RevisionConflict | message | The record changed in between, or a settings change is still pending. |
IdempotencyConflict | message | Folio saw the same request with different content. |
InvalidRequest | message | The request is not valid, for example a lifecycle move that is not allowed. |
ChallengeFailed | message | The passcode of an invite is wrong. |
InviteExpired | message | The invite has expired. |
InviteNotPending | message | The invite was already used, rejected or revoked. |
InviteNotFound | message | No invite has the given id or link id. |
QuotaExceeded | message | The account has reached a storage limit. |
RecordTooLarge | message | The record is larger than Folio accepts. |
TokenInvalid | message | Folio did not accept a vault access token, such as the token of an attachment download or of a record in another region. |
AttachmentNotFound | message | An attachment id is unknown. |
Unauthorized | message | No session holds a vault, or Folio refused the session. |
EmptyUpdate | message | The update changes nothing. |
Codec | message | A payload is not valid JSON, or stored data could not be decoded. |
Crypto | message | Encryption or decryption failed. |
Api | code, message | Folio returned an error without a more specific case. |
Storage | message | Local storage failed. |
Custody | message | The device key store failed. |
DraftNotFound | handle | An internal draft is gone. |
InvalidWrappedKey | message | A record key or an invite key could not be unwrapped. |
MissingIdentityKey | none | The account key is missing on this device. |
UnsupportedRecordType | recordType | Folio returned a record type the SDK does not know. |
Offline | none | The operation needs a connection. |
OfflineFolderOpNotSupported | none | A folder change was requested while the device is offline. |
UpstreamUnavailable | message | Folio or the network status is not reachable. |
AttachmentNotUploaded | none | An attachment has not been uploaded yet. |
AttachmentClientIdMismatch | none | Folio returned a different attachment id. |
AttachmentCorrupted | recordClientId, attachmentClientId | An attachment failed its integrity check. |
TransientNotSupported | none | The action needs a signed-in session. |
EncoderUnavailable | format: ImageFormat | The platform has no encoder for an image format. |
RecordNotApplied | recordId, message | A record from Folio, or the copy stored on the device, could not be decoded or applied. |
RecordIdNotReserved | recordId | Folio did not reserve the record id for this account. |
RecordIdMismatch | expected, received | Folio returned a different id for a created record. |
UnsupportedBodyVersion | version: u32 | A stored body has a version the SDK cannot read. |
UnknownSchema | schema | The schema name breaks the naming rule, or no body accepts it. |
SchemaMismatch | recordId, expected, received | An update names a different schema than the record has. |
InvalidRegistry | message | The vault was set up with conflicting schemas. |
UnregisteredSchema | schema | No body is registered for the schema. |
FileTooLarge | limitBytes: u32 | A file is larger than limitBytes bytes. |
Payload fields are strings unless the table names another type.
| Platform | Case with a payload | Case without a payload |
|---|---|---|
| iOS | .notFound(message:), .api(code:message:) | .permissionDenied |
| Android | VaultError.NotFound(message), a data class | VaultError.PermissionDenied, a data object |
| Web | { type: 'NOT_FOUND', value: { message } } | { type: 'PERMISSION_DENIED' } |
On the web, type is the case name in SCREAMING_SNAKE_CASE and the payload fields are under
value. See Errors for how SDK errors reach your app.
switch vault.state.error?.details {
case .transientNotSupported?:
showSignIn()
case .notFound(let message)?:
showError(message)
default:
break
}
try vault.dispatch(action: .dismissError)Types on each platform
Structs such as RecordContent, VaultRecord and RecordHeader have the field names above on
every platform. The tables on this page write enum cases in UpperCamelCase and field types in a
neutral form:
| On this page | iOS | Android | Web |
|---|---|---|---|
String | String | String | string |
Bool | Bool | Boolean | boolean |
u32 | UInt32 | Long | number |
i64 | Int64 | Long | bigint |
[T] | [T] | List<T> | T[] |
T (optional) | T? | T? | T | undefined |
The enums of the vault without payloads have these spellings:
| Enum | iOS | Android | Web |
|---|---|---|---|
Permission | .owner, .view | Permission.Owner, Permission.View | { type: 'OWNER' }, { type: 'VIEW' } |
RecordState | .active, .deleted, .permanentlyDeleted | RecordState.ACTIVE, RecordState.DELETED, RecordState.PERMANENTLY_DELETED | 'ACTIVE', 'DELETED', 'PERMANENTLY_DELETED' |
ConflictState | .none, .remoteUpdated, .remoteDeleted, .remoteRevoked, .writeRejected | ConflictState.None, ConflictState.RemoteUpdated, and so on | { type: 'NONE' }, { type: 'REMOTE_UPDATED' }, and so on |
ConflictResolution | .keepLocal, .acceptRemote | ConflictResolution.KeepLocal, ConflictResolution.AcceptRemote | ConflictResolution.keepLocal, ConflictResolution.acceptRemote |
On the web, every enum also has a constant of the same name that holds its values, such as
RecordState.ACTIVE for 'ACTIVE' and ConflictState.none for { type: 'NONE' }. Enums with
payloads, such as VaultAction, VaultWrite, DocSyncPhase and VaultError, are lowerCamelCase
cases with labeled payloads on iOS, subclasses on Android and objects with type and value on the
web.