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.

ValueRule
organizationNot empty; lowercase letters a-z, digits 0-9 and -. An invalid value fails FolioSdk.create.
schemaA 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.

FieldTypeMeaning
schemaStringYour name for the kind of document, without the organization.
versionu32Your version of the document format. The SDK stores it next to the payload.
payloadStringYour 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.

FieldTypeMeaning
versionu32Your version of the settings format.
payloadStringYour 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.

ActionPayloadEffect
SyncnonePushes local changes, then fetches remote changes.
WriteVaultWriteCreates, changes or removes a record or a folder. See below.
ShareSharingRequestCreates, lists, accepts or revokes invites. See Sharing.
DismissErrornoneClears 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

WriteFieldsEffect
Createrecord: RecordContentCreates a record owned by the signed-in account.
Updateid: String, record: RecordContentReplaces the body of a record.
Deleteid: StringMoves an active record to the deleted state.
Restoreid: StringMoves a deleted record back to the active state.
PermanentlyDeleteid: StringRemoves a deleted record for good.
UpdateSettingsid: String, settings: DocumentSettingsReplaces the signed-in account's settings of a record.
CreateFolderfolder: FolderContent, open: BoolCreates a folder. open is the folder's initial open flag.
UpdateFolderid: String, folder: FolderContentReplaces the body of a folder.
SetFolderOpenid: String, open: BoolSets the folder's open flag.
DeleteFolderid: StringRemoves a folder for good. Folders have no deleted state.
AddToFolderfolderId: String, recordId: StringPuts a record into a folder.
RemoveFromFolderfolderId: String, recordId: StringTakes a record out of a folder.
UpdateFolderSettingsid: String, settings: DocumentSettingsReplaces the signed-in account's settings of a folder.
ResolveConflictid: String, resolution: ConflictResolutionSettles 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, Restore and PermanentlyDelete fail with PermissionDenied on a record whose permission is View. Update also fails with PermissionDenied on a record that is not active.
  • A record keeps its schema. Update with a different schema fails with SchemaMismatch.
  • Lifecycle moves one step at a time. Active to deleted (Delete), deleted to active (Restore), deleted to permanently deleted (PermanentlyDelete). Any other move fails with InvalidRequest.
  • Folder changes need a connection in a signed-in session. In a signed-in session, CreateFolder, UpdateFolder, SetFolderOpen, DeleteFolder, AddToFolder and RemoveFromFolder fail with OfflineFolderOpNotSupported while the device is offline, and with UpstreamUnavailable when 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, AddToFolder fails with InvalidRequest while the folder or the record has not been synced yet. Sync, then retry. AddToFolder of a permanently deleted record fails with InvalidRequest.
  • A pending settings change blocks a body change. While a settings change of a record has not reached Folio yet, Update, Delete, Restore and PermanentlyDelete of that record fail with RevisionConflict. 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.

FieldTypeMeaning
availableBooltrue 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.
syncDocSyncStatusThe sync status of the vault.
sharingSharingViewThe results of Share actions. See Sharing.
loadingBooltrue until the store has read the vault for the first time.
busyBooltrue while a Sync, Write or Share action runs.
errorVaultErrorView (optional)The error of the last failed action, or the error of reading the vault.

VaultRecord

FieldTypeMeaning
headerRecordHeaderIdentity, ownership, lifecycle and sync flags.
metadataRecordMetadataThe account's settings and the folders the record is in.
payloadStringYour JSON document as your app wrote it, without whitespace around the value.

RecordHeader

FieldTypeMeaning
idStringThe record id. Use it in Update, Delete and every other action.
schemaStringYour schema name, without the organization.
versionu32The version your app wrote with the body.
ownerIdStringThe account that owns the record.
permissionPermissionOwner for the account's own records, View for records shared with it.
stateRecordStateActive, Deleted or PermanentlyDeleted.
createdAti64 (optional)Creation time, milliseconds since the Unix epoch. Absent when unknown.
updatedAti64 (optional)Time of the last change, milliseconds since the Unix epoch. Absent when unknown.
revokedAti64 (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.
syncRecordSyncThe 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

FieldTypeMeaning
settingsDocumentSettings (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

FieldTypeMeaning
conflictConflictStateNone, or the kind of conflict the record is in.
dirtyBooltrue while the record has local changes that Folio has not seen.

Folder

FieldTypeMeaning
headerRecordHeaderThe same header as a record.
metadataFolderMetadataSettings, members and the open flag.
payloadStringYour JSON folder document.

FolderMetadata

FieldTypeMeaning
settingsDocumentSettings (optional)The signed-in account's settings of the folder.
records[String]Ids of the records in the folder, sorted.
openBoolThe 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

FieldTypeMeaning
phaseDocSyncPhaseWhat the sync is doing.
pendingPushCountu32Local changes waiting to be pushed.
conflictCountu32Records in a conflict.
recordFailureCountu32Records whose local changes the last push could not send.
attachmentFailureCountu32Attachments the last fetch could not download or clean up, plus local attachment files a later write, such as PermanentlyDelete, could not remove.
lastSuccessfulSyncAtMillisi64 (optional)Time of the last successful fetch, milliseconds since the Unix epoch.
isSyncingBooltrue while phase is Syncing.

DocSyncPhase

PhasePayloadMeaning
IdlenoneNo sync is running.
SyncingnoneA sync is running.
Degradedmessage: StringSome records or attachments failed in the last sync or in a later local cleanup.
Errormessage: StringThe 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.

ConflictStateMeaning
NoneNo conflict.
RemoteUpdatedThe record changed on Folio after your local change was made.
RemoteDeletedThe record was deleted on Folio.
RemoteRevokedThe account lost access to the record.
WriteRejectedFolio rejected the local change.

Settle a conflict with VaultWrite.ResolveConflict and a ConflictResolution:

ResolutionEffect
KeepLocalPushes the local version over the remote one. Only for RemoteUpdated; any other conflict fails with InvalidRequest.
AcceptRemoteDrops 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:

FieldTypeMeaning
errorCodeStringThe code of the case: vault. and the case name in lowerCamelCase, such as vault.notFound.
detailsVaultErrorThe 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.

ErrorPayloadWhen
NotFoundmessageNo record or folder has the given id.
PermissionDeniednoneThe account is not allowed to change the record.
RevokednoneThe account's access to the record was revoked.
PolicyDeniednoneFolio's policy refused the request.
RevisionConflictmessageThe record changed in between, or a settings change is still pending.
IdempotencyConflictmessageFolio saw the same request with different content.
InvalidRequestmessageThe request is not valid, for example a lifecycle move that is not allowed.
ChallengeFailedmessageThe passcode of an invite is wrong.
InviteExpiredmessageThe invite has expired.
InviteNotPendingmessageThe invite was already used, rejected or revoked.
InviteNotFoundmessageNo invite has the given id or link id.
QuotaExceededmessageThe account has reached a storage limit.
RecordTooLargemessageThe record is larger than Folio accepts.
TokenInvalidmessageFolio did not accept a vault access token, such as the token of an attachment download or of a record in another region.
AttachmentNotFoundmessageAn attachment id is unknown.
UnauthorizedmessageNo session holds a vault, or Folio refused the session.
EmptyUpdatemessageThe update changes nothing.
CodecmessageA payload is not valid JSON, or stored data could not be decoded.
CryptomessageEncryption or decryption failed.
Apicode, messageFolio returned an error without a more specific case.
StoragemessageLocal storage failed.
CustodymessageThe device key store failed.
DraftNotFoundhandleAn internal draft is gone.
InvalidWrappedKeymessageA record key or an invite key could not be unwrapped.
MissingIdentityKeynoneThe account key is missing on this device.
UnsupportedRecordTyperecordTypeFolio returned a record type the SDK does not know.
OfflinenoneThe operation needs a connection.
OfflineFolderOpNotSupportednoneA folder change was requested while the device is offline.
UpstreamUnavailablemessageFolio or the network status is not reachable.
AttachmentNotUploadednoneAn attachment has not been uploaded yet.
AttachmentClientIdMismatchnoneFolio returned a different attachment id.
AttachmentCorruptedrecordClientId, attachmentClientIdAn attachment failed its integrity check.
TransientNotSupportednoneThe action needs a signed-in session.
EncoderUnavailableformat: ImageFormatThe platform has no encoder for an image format.
RecordNotAppliedrecordId, messageA record from Folio, or the copy stored on the device, could not be decoded or applied.
RecordIdNotReservedrecordIdFolio did not reserve the record id for this account.
RecordIdMismatchexpected, receivedFolio returned a different id for a created record.
UnsupportedBodyVersionversion: u32A stored body has a version the SDK cannot read.
UnknownSchemaschemaThe schema name breaks the naming rule, or no body accepts it.
SchemaMismatchrecordId, expected, receivedAn update names a different schema than the record has.
InvalidRegistrymessageThe vault was set up with conflicting schemas.
UnregisteredSchemaschemaNo body is registered for the schema.
FileTooLargelimitBytes: u32A file is larger than limitBytes bytes.

Payload fields are strings unless the table names another type.

PlatformCase with a payloadCase without a payload
iOS.notFound(message:), .api(code:message:).permissionDenied
AndroidVaultError.NotFound(message), a data classVaultError.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 pageiOSAndroidWeb
StringStringStringstring
BoolBoolBooleanboolean
u32UInt32Longnumber
i64Int64Longbigint
[T][T]List<T>T[]
T (optional)T?T?T | undefined

The enums of the vault without payloads have these spellings:

EnumiOSAndroidWeb
Permission.owner, .viewPermission.Owner, Permission.View{ type: 'OWNER' }, { type: 'VIEW' }
RecordState.active, .deleted, .permanentlyDeletedRecordState.ACTIVE, RecordState.DELETED, RecordState.PERMANENTLY_DELETED'ACTIVE', 'DELETED', 'PERMANENTLY_DELETED'
ConflictState.none, .remoteUpdated, .remoteDeleted, .remoteRevoked, .writeRejectedConflictState.None, ConflictState.RemoteUpdated, and so on{ type: 'NONE' }, { type: 'REMOTE_UPDATED' }, and so on
ConflictResolution.keepLocal, .acceptRemoteConflictResolution.KeepLocal, ConflictResolution.AcceptRemoteConflictResolution.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.

On this page