Custodian
ReferenceHTTP API

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:

sensitivityThe server holdsA read can return
plainthe value in the clearthe value
maskedthe value, encryptedthe value, or a masked rendering
tokenisedthe value, encryptedthe value, a masked rendering, or a token
client_encryptedbytes it cannot reada 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:

maskRenders
year_onlya date reduced to its year
keep_domainan email address with its local part masked and its domain kept
keep_last:Nall but the last N characters masked (N a positive whole number)
keep_first:Nall 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 changes array names every field whose sensitivity would change, and migration_path states 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 the allow_destructive flag, 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 changes array names each destructive change, so you can see exactly what re-sending with the flag would apply.

    Changing a field's type to or from file is destructive in both directions — including file → string — because it orphans every stored file, so it needs allow_destructive. (A type change from any other type to string widens 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.

On this page