Permissions · the core thesis
Scoped in the index, not in the prompt
This is the page that matters most. Verity's whole claim is that an agent scoped to customer A can never surface customer B's data — and that this is enforced architecturally, in the index query itself, never delegated to the model. This page builds from the simple model to the advanced edges, with worked examples throughout, so that by the end you can say exactly who can see what, and why.
If you internalize one thing: a scope handle decides what an agent sees, and it is set server-side, never by the agent. Everything else on this page is depth — the model that produces the handle, and the edges that keep it honest under real-world change.
Want the copy-paste version? Jump to the scoping recipes or the Cookbook.The thesis
The industry default is to stuff context into a prompt and hope the model withholds what it shouldn't reveal. The literature says it won't: prompting-based memory isolation leaks up to 69% under adversarial probing. Verity's answer is structural:
And it is measured, not asserted: the Scoped Recall Benchmark runs 1220 adversarial probes (including 220 prompt-injection-shaped queries under a fixed customer-A session targeting customer B's pricing) across every read path and reports 0 leaked items. Failure is reportable: a nonzero leak fails the run loudly.
The model
Permissions live in four planes above one identity plane. The read path never calls the authorization engine for the common classes; the correctness and the speed come from the same precomputation.
Principals (tokens)
A principal is a canonical identity a permission can name — a user, a group, or an agent. Every principal is materialized to a small integer token per tenant, so filtering is a fast bitmap operation, not a string join. A caller's identity resolves to a set of tokens: the user plus its transitive group closure.
Identity is never agent-supplied. In the MCP server, the tenant, principals, and actor sub/azp come from process configuration (env/CLI) and are not exposed in any tool schema. An agent cannot talk its way into a different caller.
# materialize principal strings → integer tokens (admin plane)
curl -s -X POST "$VERITY/v1/admin/principals" -H "authorization: Bearer $VERITY_ADMIN_TOKEN" \
-d '{"tenant_id":"'"$TENANT"'","principals":["user:alice@corp.example","group:sales"]}'
# → { "mappings": { "user:alice@corp.example": 7, "group:sales": 11 } }Entity scope
Principals answer “who”; entity scope answers “about what.” A scope handle may bind an entity_scope — a set of entity tags like ["account:acme"]. Reads are then filtered to those entities and writes may only tag them. This is the mechanism that separates two callers who hold identical permission tokens.
Entity binding has strict subset semantics (enforced by resolve_entities on every write):
- In an unbound scope (empty
entity_scope), requested tags pass through as given. - In a bound scope, an empty request inherits the whole scope; a request naming tags must be a subset of the scope, else 403.
Confidentiality classes
Every item carries one of four confidentiality classes; a handle carries a ceiling (max_confidentiality, default internal) and can never retrieve above it.
public- Freely shareable.
internal- The default class for org content.
confidential- Sensitive; requires an explicit ceiling.
restricted- Where pricing, quotes, and negotiation terms land by default. This class gets a mandatory live recheck (see below) — the tightest guarantee, on exactly the customer-A/customer-B data.
Scope handles (HMAC)
A session calls open_scope to mint an HMAC-signed scope handle. The handle is the signed, tamper-evident encoding of the whole scope. Every subsequent verb verifies the signature and enforces from the signed payload only — request arguments cannot widen it.
curl -s -X POST "$VERITY/v1/scopes" -H 'content-type: application/json' -d '{
"tenant_id": "'"$TENANT"'",
"principals": [7, 11], // user:alice + group:sales tokens
"entity_scope": ["account:acme"], // bind to Acme only
"max_confidentiality": "confidential", // ceiling; default is "internal"
"actor_sub": "user:alice@corp.example",
"actor_azp": "agent:sales-bot",
"ttl_seconds": 3600
}'
// → { "scope_handle": "vs_9f2...hmac", "expires_at": "2026-07-10T18:00:00Z" }Key properties, each load-bearing:
- Minted per session, short-lived. Default TTL 3600 s; the handle expires and cannot be refreshed silently.
- Cannot be widened by arguments. A
recallthat names entities outside the handle's scope is rejected; there is no argument that raises the confidentiality ceiling or adds a principal. - Tampering is a 401. Flip one character and the HMAC verification fails —
UNAUTHORIZED, no fallback. - The only credential Verity issues. Everything else is BYOT.
The guarantee: the eve-bot example
Here is the property, made concrete. Three agents share one memory. sales-bot and support-bot are scoped to account:acme; eve-bot holds the same org-level principal token but is scoped to account:globex. The only difference between eve and the others is entity scope — and that is enough.
# all three mint from the SAME principal set [11]; entity scope is the only difference
mint() { curl -s -X POST "$VERITY/v1/scopes" -H 'content-type: application/json' -d "{
\"tenant_id\":\"$TENANT\",\"principals\":[11],\"entity_scope\":$1,\"actor_azp\":\"$2\"}" | jq -r .scope_handle; }
SALES=$(mint '["account:acme"]' agent:sales-bot)
EVE=$(mint '["account:globex"]' agent:eve-bot)Now eve, holding a legitimate org credential, attacks Acme's data every way it can. Every path fails closed:
# 1) recall Acme's renewal quote → 0 results (out-of-scope memory never reaches the model)
curl -s -X POST "$VERITY/v1/recall" -d "{\"scope_handle\":\"$EVE\",\"text\":\"acme renewal quote amount\",\"k\":8}" | jq length
# → 0
# 2) brief of account:acme → 0 items (items always re-derived under the caller's scope)
curl -s "$VERITY/v1/briefs/account:acme?scope_handle=$EVE" | jq '.recent_memory + .recent_activity | length'
# → 0
# 3) write an action tagged to account:acme → HTTP 403 (entities outside the scope)
curl -s -o /dev/null -w '%{http_code}\n' -X POST "$VERITY/v1/actions" -d "{
\"scope_handle\":\"$EVE\",\"action_id\":\"x\",\"action_type\":\"email.sent\",
\"entities\":[\"account:acme\"],\"summary\":\"x\",\"outcome\":\"succeeded\",
\"occurred_at\":\"2026-07-10T00:00:00Z\"}"
# → 403
# 4) tamper with the handle (append two chars) → HTTP 401
curl -s -o /dev/null -w '%{http_code}\n' -X POST "$VERITY/v1/recall" -d "{\"scope_handle\":\"${EVE}00\",\"text\":\"x\"}"
# → 401This is exactly what deploy/demo.sh demonstrates on each run, and exactly what the fuzzer probes in CI.
How ACLs inherit from sources
Where do a principal's powers come from? From the sources themselves. A tier-A connector reads a source's real ACL and mirrors it into the permission graph, so a Verity scope inherits exactly the source's permissions.
- Drive permissions → principals.
type=user→user:<email>,type=group→group:<email>,type=domain→group:domain:<domain>.anyoneand anything unmappable quarantines. - Nested groups via SpiceDB. A Drive ACL granting
group:eng-leadsis only as good as our closure ofeng-leads. SpiceDB resolves nested-group membership (Groups can contain Groups) so the transitive closure is correct. The Google Admin SDK directory sync populates this — it is a distinct connector surface from Drive, because correct closure needs the Directory API, not the Drive API. - The identity plane stitches it together. Every source expresses ACLs in its own vocabulary (Google account IDs, Salesforce user/role IDs, …). Without identity resolution, no visibility token could ever match a caller and every query would fail closed to empty. The crosswalk maps source-local principals to canonical ones, preferring provider-verified joins (SSO subject) over the explicitly risk-labeled email-keyed fallback (off by default — a mutable email is an attack surface).
Add group membership through the tuple plane:
curl -s -X POST "$VERITY/v1/admin/groups" -H "authorization: Bearer $VERITY_ADMIN_TOKEN" \
-d '{"tenant_id":"'"$TENANT"'","group":"group:eng-leads","member":"user:alice@corp.example"}'
# nested groups are legal too: {"group":"group:eng-leads","member":"group:eng"}Fail-closed semantics
Every ambiguous case resolves to less access, never more. This is a design invariant, not a collection of special cases.
| Situation | Result |
|---|---|
| Item has no visibility tokens | Invisible to everyone |
| Query has no resolvable subject / empty principals | Empty result — nothing matches |
| Connector can't map an ACL or a principal | Quarantined, not indexed permissively |
| Missing / absent entity scope on entity-bound content | Zero-tag content is retrievable only in explicitly broad scopes, never entity-bound |
| Tampered scope handle | 401 |
| Write tags an entity outside the scope | 403 |
| Restricted class with ReBAC disabled | Restricted hits dropped (explicit env override required to allow) |
| Group removed while a handle is live | Revocation tombstone subtracts the token at read time, immediately |
Advanced
The edges that make the guarantee hold under real-world change: revocation, sensitive re-checks, purpose clamps, and the one principled exception.
Restricted-class live recheck
Materialized visibility tokens have a small staleness window between an ACL change and the bitmap rebuild. For most classes that window is acceptable; for restricted — pricing, quotes, negotiation terms — it is not. So restricted hits get a mandatory live recheck: after recall returns candidates, restricted-class items are re-resolved against fresh membership before they leave the server (enforce_restricted, applied identically to recall and to a brief's memory leg).
- With ReBAC on: a top-k live BatchCheck against SpiceDB, ZedToken-pinned so it is at-least-as-fresh as the latest materialized ACL write — no new-enemy anomalies.
- With ReBAC off (dev): restricted hits are dropped, fail-closed, unless you set
VERITY_ALLOW_RESTRICTED_WITHOUT_REBAC=1explicitly.
Revocation tombstones
Grants can afford to propagate asynchronously; revocations cannot. When a membership is removed, Verity writes a durable tombstone before deleting the SpiceDB tuple (fail-closed ordering: a failure aborts the delete and over-hides, never under-hides). The tombstone subtracts the affected token tenant-wide for its revocation window — applied both at open_scope mint time and at every read, so even a handle minted before the revocation immediately loses the token.
curl -s -X DELETE "$VERITY/v1/admin/groups" -H "authorization: Bearer $VERITY_ADMIN_TOKEN" \
-d '{"tenant_id":"'"$TENANT"'","group":"group:eng-leads","member":"user:alice@corp.example"}'
# → { "deleted":true, "tombstones":N, "revoked_principals":["group:eng-leads",...],
# "affected_members":["user:alice@corp.example"] }
# alice — and every transitive user of a removed inner group — loses the group principal
# and all its ancestors for the revocation window, on the next read.Purpose packs (confidentiality clamp at mint)
A purpose pack is a YAML policy that clamps a scope at mint time. When open_scope carries a purpose, the pack lowers the effective confidentiality ceiling to the minimum of what the caller asked for and what the purpose allows, and it may require a non-empty entity scope. Unknown purposes are rejected (422) — fail closed, never fall through. Verity ships a starter pack (support_conversation, sales_prep, analytics_readonly, admin_audit); every active version is visible in the scope inspector and recorded in the audit log.
curl -s -X POST "$VERITY/v1/scopes" -d '{
"tenant_id":"'"$TENANT"'","principals":[7,11],
"entity_scope":["account:acme"],
"max_confidentiality":"restricted", // requested…
"purpose":"support_conversation" // …but the pack clamps it down
}'
# the minted handle's ceiling is min(requested, purpose) — a support session
# cannot read restricted pricing even if it asks for it.Entity-scope intersection (deny-by-default)
Multi-entity content uses intersection, never union. A chunk tagged with entities A and B is retrievable only in a scope covering both A and B, or in neither. This closes the “a transcript mentions two customers, so either can see it” leak. And zero-tag content is retrievable only in explicitly broad scopes — an Acme-scoped session cannot retrieve untagged content as a fallback or as filler. Derived L3 briefs carry the intersection of their lineage's visibility and entity scopes, re-checked when any ancestor narrows.
The knowledge carve-out (§7g)
There is exactly one thing retrievable in an entity-bound scope that is not tagged within that scope: a published knowledge item. Zero-tag semantics normally exclude untagged content because a missing tag might mean unclassified sensitive content. Published knowledge is the principled exception because it is not un-tagged — it is positively verified entity-free: it passed the de-identification gate, carries k-distinct-entity support, and holds status: published.
status, stamped into the index payload as kind: knowledge at publish time — never on the absence of tags. Candidates and quarantined items stay invisible outside audit scopes, and the fuzzer has dedicated cases: a quarantined item surfacing anywhere non-audit, or any tagged chunk sneaking through the carve-out, fails the build.Recipes
The exact open_scope call for each common shape. All examples assume you already have principal tokens (mint them with POST /v1/admin/principals).
Scope an agent to one account
{ "tenant_id":"...", "principals":[7,11],
"entity_scope":["account:acme"], // exactly one entity
"actor_azp":"agent:sales-bot" }Scope an agent to one team's entities
{ "tenant_id":"...", "principals":[11], // group:sales token — grants the team's ACL reach
"entity_scope":["account:acme","account:beta","account:gamma"],
"actor_azp":"agent:team-bot" }Give an agent org-wide read
{ "tenant_id":"...", "principals":[1], // the org-wide token from `verity-cli dev`
"entity_scope":[], // unbound: no entity filter (but visibility still applies)
"actor_azp":"agent:analytics" }An unbound scope still enforces visibility — org-wide means “every entity the org token can see,” not “everything.” Zero-tag content becomes retrievable here (and only here).
Resolve principals from identity instead of naming tokens
With ReBAC enabled, pass a subject and let the server resolve the user's principal plus its transitive group closure. Self-asserted principals alongside a subject are rejected — when identity is live, the caller no longer names its own powers.
{ "tenant_id":"...", "subject":"user:alice@corp.example",
"entity_scope":["account:acme"] } // principals resolved server-side (requires VERITY_SPICEDB_URL)Make something restricted
Confidentiality rides on the write. Ingest under a handle whose ceiling is high enough and mark the content restricted at its source (CDC facts for pricing land restricted by policy). To read restricted, the handle's ceiling must reach it and the live recheck must pass:
{ "tenant_id":"...", "principals":[7],
"entity_scope":["account:acme"],
"max_confidentiality":"restricted" } // still subject to the live BatchCheck on each restricted hitDecision table
| I want an agent that… | principals | entity_scope | ceiling / purpose |
|---|---|---|---|
| answers only about one account | the user/team tokens | ["account:acme"] | default internal |
| handles a support conversation, no pricing | the user tokens | ["account:acme"] | purpose:"support_conversation" (clamps) |
| preps a sales rep, may see quotes | the rep + group:sales | ["account:acme"] | "restricted" + live recheck |
| runs org-wide analytics | the org token | [] (unbound) | purpose:"analytics_readonly" |
| reads across two accounts on purpose | tokens covering both | ["account:a","account:b"] | an explicit, audited scope |
| sees nothing until granted | [] (empty) | anything | fail-closed: 0 results |
A full multi-agent walkthrough
Two sales agents and one support agent operate on the same tenant. sales-bot and prep-bot both work Acme; support-bot works Acme too but under a purpose that clamps out pricing; an analytics-bot has org-wide read. Watch the walls hold.
Setup — four handles, one tenant
mint() { curl -s -X POST "$VERITY/v1/scopes" -d "$1" | jq -r .scope_handle; }
SALES=$(mint '{"tenant_id":"'"$T"'","principals":[7,11],"entity_scope":["account:acme"],
"max_confidentiality":"restricted","actor_azp":"agent:sales-bot"}')
PREP=$(mint '{"tenant_id":"'"$T"'","principals":[7,11],"entity_scope":["account:acme"],
"actor_azp":"agent:prep-bot"}')
SUPPORT=$(mint '{"tenant_id":"'"$T"'","principals":[7],"entity_scope":["account:acme"],
"purpose":"support_conversation","actor_azp":"agent:support-bot"}')
ANALYTICS=$(mint '{"tenant_id":"'"$T"'","principals":[1],"entity_scope":[],
"purpose":"analytics_readonly","actor_azp":"agent:analytics"}')1 · sales-bot records a restricted quote; prep-bot sees it, support-bot does not
# sales-bot writes an action tagged to Acme (restricted ceiling)
curl -s -X POST "$VERITY/v1/actions" -d "{\"scope_handle\":\"$SALES\",\"action_id\":\"q1\",
\"action_type\":\"quote.issued\",\"summary\":\"Renewal quote \$84,000 net-30\",
\"outcome\":\"succeeded\",\"occurred_at\":\"2026-07-10T00:00:00Z\"}"
# prep-bot (same tokens, no purpose clamp) sees it on the Acme timeline
curl -s "$VERITY/v1/activity?scope_handle=$PREP&entity=account:acme" | jq '.[].summary'
# → "Renewal quote $84,000 net-30"
# support-bot's purpose clamps the ceiling below restricted → the pricing chunk is filtered out
curl -s -X POST "$VERITY/v1/recall" -d "{\"scope_handle\":\"$SUPPORT\",\"text\":\"renewal quote amount\"}" | jq length
# → 0 (support sees Acme, but not Acme's restricted pricing)2 · analytics-bot reads org-wide, but still can't see restricted pricing without the ceiling
curl -s -X POST "$VERITY/v1/recall" -d "{\"scope_handle\":\"$ANALYTICS\",\"text\":\"quote amount\"}" | jq length
# → 0 ceiling defaults to internal under analytics_readonly; org-wide ≠ unrestricted3 · cross-agent awareness — prep-bot checks activity before acting
curl -s "$VERITY/v1/activity?scope_handle=$PREP&entity=account:acme&action_types=quote.*" \
| jq '.[] | {who:.actor_azp, what:.action_type}'
# → { "who":"agent:sales-bot", "what":"quote.issued" }
# prep-bot won't re-issue a quote it can see sales-bot already sent.See these patterns wired into full scenarios on the use-cases page.