Grants
Give a principal exactly the access it needs over HTTP, and read the combinations the server refuses at creation.
A grant gives one principal one action on one field of one collection — or, with no field named, on every field of the collection. Nothing is inherited and nothing is implied, so what a principal can see is answered by reading its grants.
Creating and revoking a grant are reserved for operator principals. Listing grants
is not: any principal except an agent may read them, so a service can answer
"what can this principal see?" for itself. No agent reaches any of these routes.
A grant names one of four actions. Three are read actions, and each decides how much of a field's value a read returns for this principal:
read_tokenreturns a token — a stabletok_…string that stands in for the value and reveals nothing about it. Aplainormaskedfield carries no token, soread_tokenon one returns nothing usable.read_maskedreturns a masked rendering of the value — a partial value, for example a date reduced to its year or all but the last few characters hidden.read_plaintextreturns the value itself, in the clear. On aclient_encryptedfield, whose value the server cannot read, it instead lets the caller resolve the token to the sealed value on an enrolled device.
The fourth action, write, permits writing the field and does not permit reading.
Where a field-level grant and a collection-wide grant overlap, the strongest read
action wins: read_plaintext beats read_masked beats read_token. Which of these a
field's sensitivity can actually return is set out in the
schema sensitivity table and, field by field, in the
read-resolution envelope.
POST /v1/grants — create a grant
POST /v1/grants
Authorization: Bearer <operator token>
Content-Type: application/json
{
"principal": "liberty-agent",
"collection": "policies",
"field": "holder_tax_id",
"action": "read_token"
}Five keys are read, from the top level of the body: principal, collection,
field, action and expires_at. Nothing else is taken — the vault and the
principal that creates the grant are the ones your token names, never values you
put in the body. The nested { "grant": { … } } shape is also accepted: a
grant object at the top level is unwrapped, and both shapes produce the same
grant.
field is optional — omit it to grant across every field in the collection.
expires_at is optional and, when present, an ISO 8601 timestamp. On success the
response is 201 Created:
{
"grant": {
"id": 5012,
"principal": "liberty-agent",
"collection": "policies",
"field": "holder_tax_id",
"action": "read_token",
"status": "live",
"created_at": "2026-08-12T09:10:00Z",
"expires_at": null,
"revoked_at": null
}
}A null field in the response is the collection-wide wildcard — "every field" —
written as null rather than omitted.
The combinations refused at creation
The server refuses, at creation, the combinations that could never be honoured, so the mistake is caught while you configure it rather than during an incident. Each carries a closed error code:
-
plaintext_grant_to_agent— aread_plaintextgrant to anagenton aclient_encryptedfield, which an agent can never hold in the clear:{ "code": "plaintext_grant_to_agent", "message": "refused: principal 'liberty-agent' is kind=agent; read_plaintext on a client_encrypted field is not grantable to an agent. Grant read_token instead, and resolve the value with `custodian exec` on an enrolled device.", "details": { "kind": "agent", "sensitivity": "client_encrypted", "field": "holder_tax_id" } } -
collection_read_plaintext_to_agent— aread_plaintextgrant to anagentacross a whole collection, refused whatever the collection holds today, because a field added later would be covered by it:{ "code": "collection_read_plaintext_to_agent", "message": "refused: principal 'liberty-agent' is kind=agent; read_plaintext across a whole collection is not grantable to an agent, because a field added later would be covered by it. Name the fields this principal needs.", "details": { "kind": "agent" } } -
grant_to_revoked_principal— a grant to a principal that has been revoked:{ "code": "grant_to_revoked_principal", "message": "refused: principal 'liberty-agent' was revoked at 2026-08-20T00:00:00Z; a grant to a revoked principal has no effect — the resolver treats its grants as absent — so accepting one would leave you believing access exists.", "details": { "principal": "liberty-agent", "revoked_at": "2026-08-20T00:00:00Z" } } -
grant_expiry_in_past— an expiry already past:{ "code": "grant_expiry_in_past", "message": "refused: the grant's expires_at 2026-08-01T00:00:00Z is already past (the time is now 2026-08-28T10:00:00Z); a grant that is dead on arrival is a configuration mistake, not access.", "details": { "expires_at": "2026-08-01T00:00:00Z", "now": "2026-08-28T10:00:00Z" } } -
duplicate_live_grant(409) — a duplicate of a live grant:{ "code": "duplicate_live_grant", "message": "A live grant (id 5012, granted at 2026-08-12T09:10:00Z) already covers this principal, collection, field \"holder_tax_id\" and action; a duplicate would add nothing.", "details": { "action": "read_token", "existing_grant_id": 5012, "created_at": "2026-08-12T09:10:00Z" } } -
field_not_in_collection— a field that is not in the named collection:{ "code": "field_not_in_collection", "message": "\"premium\" is not a field of collection \"claims\"; a grant names a field of the collection it grants on, or no field at all for every field in it.", "details": { "field": "premium", "collection": "claims" } }
A request that omits principal, collection or action is a 400 — a
malformed request, not a statement about the vault's contents — naming the key it
dropped, with no error code:
{
"error": "A grant names a principal; this request did not. A grant is created from principal, collection and action, with an optional field and expires_at."
}field and expires_at are optional, so their absence is never refused here.
A request body that is not valid JSON is also a 400, the same refusal the other body-reading routes give:
{
"error": "the request body is not valid JSON."
}An unknown action, principal or collection, or an unparseable expires_at, is a
plain 422 or 404 naming the value, with no error code.
GET /v1/grants — list grants
Lists the vault's grants, live by default. Filter with principal (slug),
collection (name) and field (name); widen with include_revoked and
include_expired. Every row states its status, so "what can this principal see?"
is answered from the rows alone.
GET /v1/grants?principal=liberty-agent
Authorization: Bearer <token>{
"grants": [
{
"id": 5012,
"principal": "liberty-agent",
"collection": "policies",
"field": "holder_tax_id",
"action": "read_token",
"status": "live",
"created_at": "2026-08-12T09:10:00Z",
"expires_at": null,
"revoked_at": null
}
]
}DELETE /v1/grants/:id — revoke a grant
Stamps the grant revoked_at and leaves the row.
DELETE /v1/grants/5012
Authorization: Bearer <operator token>{
"grant": {
"id": 5012,
"principal": "liberty-agent",
"collection": "policies",
"field": "holder_tax_id",
"action": "read_token",
"revoked_at": "2026-08-28T10:00:00Z"
}
}Revocation is idempotent: a second call reports the original revoked_at. An
unknown identifier takes the shared not_found.