Skip to content

Managing Facts

Your .vd policy defines the rules of your authorization model, while facts represent the live state of your application: who belongs to which organization, which folder contains a document, and who has been granted access.

This guide covers how to organize fact ownership between your database and Verdict Server, update single-valued relationships atomically, and clean up facts when resources or users are removed.

You do not need to mirror every column of your application database into Verdict Server. Look at the declare declarations and rules in your .vd policy and supply only the relationships, roles, and attributes that your rules actually inspect. Application payload fields such as document titles, markdown content, email addresses, and display timestamps stay in your application database.

For the facts your policy does inspect, choose between two ownership patterns:

PatternSource of truthExamplesHow your backend manages it
Synchronized domain dataYour application databasebelongs_to(Document, Folder), is_owner(User, Document), is_archived(Document)Write or update the corresponding fact in Verdict whenever your backend creates, updates, or deletes the domain record.
Verdict-managed grantsVerdict Servershared_to(Document, User), has_permission(User, string, Document)Insert, delete, and list the grant directly in Verdict Server without maintaining a duplicate table in your database.

Keeping pure access grants (like direct document shares) directly in Verdict avoids schema duplication when your application does not need extra relational columns on the grant. Conversely, keeping structural domain data (like which folder a document lives in) in your database lets your application render folder trees with standard SQL queries while synchronizing the belongs_to link to Verdict for authorization.

Client facts use [predicate, ...terms] tuples. The identity("User", "alice") helper represents the typed identity User:alice; a plain string "alice" is a different value. Use the same representations in writes and checks, as described in Values and identities and the client reference.

When creating a resource that establishes multiple initial facts, use batchFacts (POST /v1/facts/batch) so all initial relationships are recorded in a single atomic request:

import { identity } from "@useverdict/client";
async function createDocument(ownerId: string, folderId: string, documentId: string) {
const document = identity("Document", documentId);
await client.batchFacts({
inserts: [
["is_owner", identity("User", ownerId), document],
["belongs_to", document, identity("Folder", folderId)],
],
});
}

Both insertFact and batchFacts inserts are idempotent: inserting a fact that already exists in Verdict Server succeeds without creating a duplicate entry.

For Verdict-managed grants such as sharing a document with a collaborator, write the grant with insertFact and inspect existing grants for a UI settings view with listFacts (GET /v1/facts):

// Grant User:bob access to Document:handbook
await client.insertFact(["shared_to", identity("Document", "handbook"), identity("User", "bob")]);
// List all stored shared_to facts for Document:handbook
const page = await client.listFacts(["shared_to", identity("Document", "handbook"), null]);
const sharedUsers = page.facts.map(([, , user]) => user);

Notice the difference between listFacts and query: listFacts returns only raw stored facts matching the filter (here, users explicitly added via shared_to), whereas query evaluates your policy rules to include everyone who derives access through ownership or organization membership.

Predicate names in Verdict do not enforce single-value uniqueness. If Document:handbook is currently in Folder:engineering (belongs_to(Document:handbook, Folder:engineering)) and you call insertFact with Folder:guides, Verdict stores both facts, making the document appear inside both folders at once.

Whenever a state change replaces an existing relationship, role, or attribute value, use batchFacts to delete the prior fact and insert the new one in a single transaction. Verdict Server always applies all deletes before inserts:

// Move Document:handbook into Folder:guides
const document = identity("Document", "handbook");
await client.batchFacts({
deletes: [
// Remove whichever folder Document:handbook previously belonged to
["belongs_to", document, null],
],
inserts: [["belongs_to", document, identity("Folder", "guides")]],
});

Passing null (or { wildcard: { type: "Folder" } }) in the delete pattern removes any existing belongs_to fact for Document:handbook before inserting Folder:guides, so your handler does not need to look up the previous folder ID first.

Dot syntax does not impose uniqueness either; separate attribute lookups can match different recorded values. Use the same pattern when updating single-valued attributes (such as clearance_level(User:alice, 3)) or changing a user’s role within an organization:

// Change User:alice's role in Organization:acme to "admin"
await client.batchFacts({
deletes: [["has_role", identity("User", "alice"), null, identity("Organization", "acme")]],
inserts: [["has_role", identity("User", "alice"), "admin", identity("Organization", "acme")]],
});

When a resource is deleted in your application, remove its stored facts so stale relationships do not linger in Verdict Server. Because batchFacts deletes accept null and typed wildcards in any term position, and deleting a pattern that matches zero stored facts succeeds without error, you can clean up every predicate referencing a resource in one call:

async function deleteDocumentFacts(documentId: string) {
const document = identity("Document", documentId);
await client.batchFacts({
deletes: [
["is_owner", null, document],
["belongs_to", document, null],
["shared_to", document, null],
["is_archived", document],
],
});
}

Keep additive paths in mind when revoking a user’s access. As covered in Relationships, an authorization check succeeds if any rule and fact path proves allow and no deny matches. If you offboard User:alice from Organization:acme, deleting only has_role(User:alice, _, Organization:acme) removes her organization-wide role, but any direct document shares (shared_to(Document:handbook, User:alice)) remain active unless you delete those grants as well.