Schema
Apply a schema document, read the current schema, and read its version history over HTTP — with the refusals that guard a change.
A vault's schema describes its collections and their fields. Each field declares
a type and a sensitivity, and the sensitivity decides how the field's value is
stored and who can ever see it:
sensitivity | The server holds | A read can return |
|---|---|---|
plain | the value in the clear | the value |
masked | the value, encrypted | the value, or a masked rendering |
tokenised | the value, encrypted | the value, a masked rendering, or a token |
client_encrypted | bytes it cannot read | a token — the value is only ever recovered on a device |
These routes are principal-authenticated and reach only the caller's own vault.
POST /v1/schema — apply a schema document
The body is a schema document: the vault it describes, the version it was written against, and its collections and fields.
POST /v1/schema
Authorization: Bearer <operator token>
Content-Type: application/json
{
"vault": "acme",
"version": 3,
"collections": {
"policyholders": {
"fields": {
"party_type": { "type": "enum", "sensitivity": "plain", "values": ["natural_person", "company"] },
"date_of_birth": { "type": "date", "sensitivity": "masked", "mask": "year_only" },
"sa_id_number": { "type": "string", "sensitivity": "client_encrypted" }
}
},
"policies": {
"parent": "policyholders",
"fields": {
"policy_number": { "type": "string", "sensitivity": "plain", "searchable": true, "unique": true, "required": true },
"premium": { "type": "integer", "sensitivity": "tokenised" }
}
}
}
}version is a staleness check, not an instruction: the server assigns version
numbers, and an apply must state the version currently applied to the vault. A first
apply to a vault with no schema ignores it and becomes version 1.
A field declares type (string, integer, boolean, date, enum or file)
and sensitivity (required, no default). An enum field declares its values; a
masked field declares its mask rule; a searchable field may set index_bits,
the width of its blind index (default 16); a file field may name a filename_field
(see File fields). A collection may name a parent collection in the
same document.
A masked field must name one of these four mask rules, and no others; any other
value is refused when the document is applied:
mask | Renders |
|---|---|
year_only | a date reduced to its year |
keep_domain | an email address with its local part masked and its domain kept |
keep_last:N | all but the last N characters masked (N a positive whole number) |
keep_first:N | all but the first N characters masked (N a positive whole number) |
A masked value renders its masked positions as the bullet character •.
File fields
A field declared type: "file" holds a file's bytes. It is always
client_encrypted: your device seals the bytes, and the server holds ciphertext it
cannot read. A file's bytes do not travel in a record body — they move over their own
routes, one file per request; see Record files.
A file field must be client_encrypted, and may not be searchable, unique or
required. Each is refused when the document is applied, in the product's own words:
- not
client_encrypted— "file field "policyholders.passport_scan" names sensitivity "masked"; a file field must be client_encrypted — a plain file would leave the server holding readable bytes, and masked and tokenised mean nothing for a file" searchable— "file field "policyholders.passport_scan" is searchable; a blind index over file bytes only discloses equality between files and buys nothing"unique— "file field "policyholders.passport_scan" is unique; uniqueness over file bytes is meaningless"required— "file field "policyholders.passport_scan" is required; a file's bytes are written after the record exists, so a required file field would make the collection uncreatable"
A file field carries no values and no mask; declaring either is refused the same
way any field that declares them out of place is.
A file field may name a sibling field to hold the file's name, with the optional
filename_field. The named sibling must be in the same collection, declared
type: "string", and not itself a file:
{
"passport_scan": { "type": "file", "sensitivity": "client_encrypted", "filename_field": "passport_scan_name" },
"passport_scan_name": { "type": "string", "sensitivity": "plain" }
}filename_field is refused when:
- it is set on a field that is not a file — "field "policyholders.note" declares filename_field but is not a file"
- it names a field that is not in the collection — "file field "policyholders.passport_scan" names filename_field "policyholders.missing", which is not a field in collection "policyholders""
- it names a field that is itself a file — "file field "policyholders.passport_scan" names filename_field "policyholders.other_scan", which is itself a file"
- it names a field that is not a string — "file field "policyholders.passport_scan" names filename_field "policyholders.born", which is declared "date", not string"
Setting or changing a file field's filename_field is an additive change — it orphans
no stored bytes — and applies without the destructive flag; it appears in the change
list as a filename_field_changed entry.
On success the response is 201 Created and returns the new version and the list of
changes the apply made:
{
"version": 4,
"changes": [
{ "change": "field_added", "collection": "policies", "field": "premium", "records_with_value": 0, "to": { "type": "integer", "sensitivity": "tokenised", "searchable": false, "required": false, "unique": false, "mask": null, "values": null, "index_bits": null, "filename_field": null } }
]
}An apply whose document is identical to the current schema is a no-op: it returns
200 OK, the unchanged version, and "changes": [].
The refusals
-
stale_schema_document— the document's version is not the one currently applied:{ "code": "stale_schema_document", "message": "The document was written against schema version 3, but this vault is on version 4. Re-fetch the current schema and re-apply.", "details": { "expected_version": 4, "provided_version": 3 } } -
sensitivity_change_refused— changing a field's sensitivity is refused outright, in either direction, flag or no flag; the whole apply is refused and nothing is applied:{ "code": "sensitivity_change_refused", "message": "policyholders.sa_id_number changes sensitivity. Sensitivity cannot be changed in place, in either direction: every stored value must be re-encrypted from an enrolled device, which the server cannot do because it holds no key. The whole apply was refused and nothing was applied.", "details": { "changes": [ { "change": "sensitivity_change_refused", "collection": "policyholders", "field": "sa_id_number", "records_with_value": 0, "from": { "sensitivity": "masked" }, "to": { "sensitivity": "tokenised" }, "refused": true, "reason": "sensitivity cannot be changed in place, in either direction; the whole apply was refused and nothing was applied" } ], "migration_path": "Re-encrypt every stored value for the field from an enrolled device under the new sensitivity, then apply; the server cannot re-encrypt in place." } }The
changesarray names every field whose sensitivity would change, andmigration_pathstates the only route to the change: re-encrypt the field's values from an enrolled device, then apply. -
destructive_change_requires_flag— a change that would orphan stored data (removing a field or a collection, for example) needs theallow_destructiveflag, sent in the query string as?allow_destructive=true:{ "code": "destructive_change_requires_flag", "message": "This apply makes 1 destructive change(s) that orphan stored data. Re-send with allow_destructive=true to apply them.", "details": { "changes": [ { "change": "field_removed", "collection": "policyholders", "field": "middle_name", "records_with_value": 0 } ] } }The
changesarray names each destructive change, so you can see exactly what re-sending with the flag would apply.Changing a field's
typeto or fromfileis destructive in both directions — includingfile→string— because it orphans every stored file, so it needsallow_destructive. (A type change from any other type tostringwidens the field and is not destructive.)
A structurally malformed document — an out-of-set value, a missing or forbidden
attribute, an unknown or cyclic parent — is a plain 422 naming the offending
value, with no error code.
Preview an apply with a dry run
To see what an apply would do without changing anything, send the body as an
envelope: put the schema document under document, and set dry_run to true
beside it.
POST /v1/schema
Authorization: Bearer <operator token>
Content-Type: application/json
{
"dry_run": true,
"document": {
"vault": "acme",
"version": 3,
"collections": { ... }
}
}A bare schema document — a body with no document key, the shape shown earlier —
is still an apply, unchanged. Only an envelope with dry_run set to true is a
dry run.
A dry run runs every judgement an apply runs — the staleness check, the sensitivity refusal and the destructive-change check — and refuses exactly what an apply would refuse, with the same code and message. Every refusal above applies identically to a dry run.
When it would proceed, the response is 200 OK and reports the change list an
apply would make, together with the version currently applied — under both
current_version and version:
{
"dry_run": true,
"current_version": 3,
"version": 3,
"changes": [
{ "change": "field_added", "collection": "policies", "field": "premium", "records_with_value": 0, "to": { "type": "integer", "sensitivity": "tokenised", "searchable": false, "required": false, "unique": false, "mask": null, "values": null, "index_bits": null, "filename_field": null } }
]
}A dry run writes nothing: no new version is assigned, and nothing is added to the audit trail. To apply the change, send the document again without the envelope.
The allow_destructive flag rides the query string (?allow_destructive=true),
never the document or the envelope. A dry run of a destructive change is refused
with destructive_change_requires_flag unless the flag is set, exactly as an
apply is.
GET /v1/schema — read the current schema
GET /v1/schema
Authorization: Bearer <principal token>{
"version": 4,
"collections": {
"policyholders": {
"parent": null,
"fields": {
"date_of_birth": {
"id": 71,
"type": "date",
"sensitivity": "masked",
"searchable": false,
"required": false,
"unique": false,
"mask": "year_only",
"values": null,
"index_bits": null
}
}
}
}
}A vault with no schema yet returns { "version": null, "collections": {} }. The
id on each field is stable across schema versions for a field that was not removed
and re-added — it is the identifier a token is bound to.
GET /v1/schema/history — read the version history
GET /v1/schema/history
Authorization: Bearer <principal token>{
"versions": [
{
"version": 4,
"applied_by": "operator",
"applied_at": "2026-08-12T09:20:00Z",
"changes": [
{ "change": "field_added", "collection": "policies", "field": "premium", "records_with_value": 0, "to": { "type": "integer", "sensitivity": "tokenised", "searchable": false, "required": false, "unique": false, "mask": null, "values": null, "index_bits": null, "filename_field": null } }
]
}
]
}Versions are newest first, each with who applied it and the changes it made.
A record write also states a schema version, and a write stating a version that
is not the record's own is refused with schema_version_mismatch — see
Records.