Resource Metadata
Resource metadata is typed, server-stored key/value data you attach to any resource — a user, a group, a collection, or a database. Metadata is grouped into named categories, and each category carries its own schema and its own CEL read/write rules, so different pieces of data about the same resource can have completely different access rules: a user's profile category might be self-editable, while a billing category is written only by a server function.
Categories and Schemas
A category is defined once per resource type — (resourceType, category) — with a schema of typed fields, plus an optional readRule and writeRule:
# primitive/dev/metadata-category-configs/user.profile.toml
[metadataCategoryConfig]
resourceType = "user"
category = "profile"
readRule = "user.userId == resource.resourceId"
writeRule = "user.userId == resource.resourceId"
description = "Per-user profile metadata"
[metadataCategoryConfig.schema.fields.tier]
type = "string"
required = true
enum = ["free", "pro", "enterprise"]
[metadataCategoryConfig.schema.fields.displayName]
type = "string"
maxLength = 120Push it like any other synced config:
primitive config pushField types are string, number, boolean, date, id, and stringset; enum is valid only on a string field. A category holds up to 100 keys and 16 KB of data, and writes are validated against the schema before they're stored.
Unique Fields and Reverse Lookup
Flag one string or id field unique = true and Primitive keeps that value distinct across every resource of its type — and lets a server function look the resource up by the value. A category may declare at most one unique field.
[metadataCategoryConfig.schema.fields.stripeCustomerId]
type = "string"
unique = trueWriting a value another resource already holds is rejected; clearing the field or deleting the row frees it. Declare unique when you first define the category — enabling it on a category that already holds data is refused (define the unique field up front, or recreate the category).
Turn a value back into the resource that owns it with resolve:
const hit = await client.resourceMetadata.resolve({
resourceType: "user",
category: "billing",
key: "stripeCustomerId",
value: "cus_ABC",
});
// -> { resourceId, resourceType } on a hit, { resourceId: null } on a misslet hit = try await client.resourceMetadata.resolve(
resourceType: "user", category: "billing",
key: "stripeCustomerId", value: "cus_ABC"
)
// hit.resolved is the owning resource on a hit, nil on a missprimitive metadata resolve user billing stripeCustomerId cus_ABCA value no resource owns is a miss, not an error: the call succeeds with resourceId: null. The category's readRule is applied to the resolved resource, and a denied read returns that same miss shape, so the response body never tells you whether a value you can't read exists (app-level owners and admins bypass the rule, as everywhere else). Only the body is indistinguishable, though — a denied resolve does the rule evaluation and its lookups that a miss skips, so response latency can still separate the two. Asking for a key that isn't the category's unique field is a 400 NOT_UNIQUE_FIELD — a configuration mistake, deliberately distinct from a miss. A server function does the same lookup through ctx.api.resourceMetadata.
readRule and writeRule are CEL expressions evaluated against user.* (the caller) and resource.* (resourceType, resourceId — also reachable as resource.id — and category) — app-level owners and admins bypass both.
When the metadata subject is a database or a collection, the rule can also read the resource's own columns through resource.attrs — for example, a creator-bootstrap rule that lets whoever created a database write its metadata:
writeRule = "user.userId == resource.attrs.createdBy"Only those two resource types (and only a fixed set of columns per type) resolve; a rule that references resource.attrs on any other resource type, or a column outside the set, denies. The bypass is the app role only: holding a permission on the resource itself (being a database's owner or manager, say) grants no bypass — the rule is what authorizes resource-scoped callers. writeRule gates the write API; readRule gates the read API. Omit either and it defaults to deny.
A category rule can also use the identity context's group-membership helpers, so access can be group-scoped instead of just self-scoped:
readRule = "isMemberOf('class-teachers', resource.id)"isMemberOf, memberGroups, and hasRole are all available; hasCollectionAccess is not — it's collection-scoped, and a category rule that uses it is rejected when the config is pushed.
Reading Other Categories from a Category Rule
A category's own readRule/writeRule can reach the resource's other categories the same way a group type's rules do — reference the category as md.self.<category>.<key> and it's loaded automatically (see Using Metadata in Access Rules):
# primitive/dev/metadata-category-configs/class-post.post.toml
[metadataCategoryConfig]
resourceType = "class-post"
category = "post"
readRule = "isMemberOf('class-teachers', md.self.classLink.classId)"
writeRule = "isMemberOf('class-teachers', md.self.classLink.classId)"
[metadataCategoryConfig.schema.fields.title]
type = "string"
required = trueReading md.self.<category>.<key> doesn't require declaring the category — it's inferred from the rule and loaded automatically, and a category that doesn't exist reads as null. An explicit [metadata.self] categories = [...] block on the config is also supported and unions with whatever the rule references directly — reach for it to load a category no rule names. secrets.<KEY> is the exception: it is declared-only, following the rule described in Access Control.
Reading and Writing Metadata
Read and write a category's data with the client or the CLI. get/set take the resource type, resource id, and category as arguments, and set fully replaces the category's data:
await client.resourceMetadata.set("user", userId, "profile", {
tier: "pro",
displayName: "Ada",
});
const profile = await client.resourceMetadata.get("user", userId, "profile");
// { resourceType, resourceId, category, data: { tier: "pro", displayName: "Ada" }, schemaVersion, exists }_ = try await client.resourceMetadata.set(
resourceType: "user", resourceId: userId, category: "profile",
data: ["tier": "pro", "displayName": "Ada"]
)
let profile = try await client.resourceMetadata.get(
resourceType: "user", resourceId: userId, category: "profile"
)
// data is [String: JSONValue]; check `exists` before reading itprimitive metadata set user 01HXY... profile --data '{"tier":"pro","displayName":"Ada"}'
primitive metadata get user 01HXY... profile --jsonMetadata reads are on-demand: a get returns the value at the moment of the call, and changes are not pushed to clients. A client that needs current values polls on whatever interval fits its freshness needs. This is a deliberate division of labor: metadata holds configuration and state the server reads, while state that must react in real time on the client belongs in a document, where connected clients receive updates automatically.
Batch Reads
Read several resources' categories in one call — useful for a listing view that would otherwise issue one request per row. The call always succeeds; per-resource and per-category problems (a missing category, a denied readRule) show up inside the results instead of failing the whole batch:
const { results } = await client.resourceMetadata.getBatch({
requests: [
{ resourceType: "user", resourceId: userA, categories: ["profile", "billing"] },
{ resourceType: "user", resourceId: userB, categories: ["profile"] },
],
});
// results[i] = { resourceType, resourceId, ok: true,
// categories: { profile: { ok: true, exists, data, schemaVersion }, billing: { ok: false, status: 403, code: "FORBIDDEN", message } } }let batch = try await client.resourceMetadata.getBatch(requests: [
.init(resourceType: "user", resourceId: userA, categories: ["profile", "billing"]),
.init(resourceType: "user", resourceId: userB, categories: ["profile"]),
])
for result in batch.results {
// Branch on `ok`, or read the `error` accessor — nil when the entry succeeded.
guard let categories = result.categories else { continue }
if let billing = categories["billing"], let error = billing.error {
print(error.status, error.code) // 403 FORBIDDEN
}
}primitive metadata get-batch --resource user:01HXY...:profile,billing --resource user:01HZQ...:profileA batch call covers up to 50 resources and 200 resource/category pairs total.
Listing and Deleting
To see everything stored on one resource — say, while debugging why a rule isn't matching — list its categories in one call; to remove a category's data, delete it:
const { categories } = await client.resourceMetadata.list("user", userId);
// [{ category: "profile", data: {...}, schemaVersion }, ...] — only categories your readRule lets you see
const { deleted } = await client.resourceMetadata.delete("user", userId, "profile");
// deleted: false when nothing was stored — deleting an absent item is not an errorlet listed = try await client.resourceMetadata.list(resourceType: "user", resourceId: userId)
// listed.categories — only categories your readRule lets you see
let removed = try await client.resourceMetadata.delete(
resourceType: "user", resourceId: userId, category: "profile"
)
// removed.deleted is false when nothing was stored — deleting an absent item is not an errorprimitive metadata list user 01HXY...
primitive metadata delete user 01HXY... profilelist returns the categories the caller may read (each category's readRule applies, and app-level owners and admins see everything — the CLI reads as an admin, so it always shows all categories). The CLI can also inspect category definitions — schema, rules, version — without opening TOML files, from their own top-level noun:
primitive metadata-category-configs list
primitive metadata-category-configs get user profileDeleting a definition is deleting its file and running primitive config push --prune, exactly like every other configuration object — there is no delete verb:
rm primitive/dev/metadata-category-configs/user.profile.toml
primitive config push --prune --dry-run # preview the deletion
primitive config push --prune--only narrows that to the one definition, so the rest of the tree is left alone:
primitive config push --only 'metadata-category-config/user#profile' --prune --dry-run
primitive config push --only 'metadata-category-config/user#profile' --pruneThe key is the resourceType#category pair the file declared, not its dotted file name. --prune is what lets the selector name a definition whose file is already gone — the sync state still tracks it, and that is what the pruning push reads. Without --prune the same selector is refused, because a scoped push with no file to apply would report success having done nothing.
Deleting a category definition is a hard delete of the definition only — it does not delete stored values. There is no query path from a category to its value rows, so any values left behind become unreachable: reads and writes for the category 404 (its definition is gone), yet the rows still occupy storage and no surface can remove them. If you need the values gone, delete them before the definition (primitive metadata delete <resource-type> <id> <category> per resource, or client.resourceMetadata.delete). Re-creating the same {resourceType, category} later resurfaces those orphaned rows bound to the new schema — they may be stale or schema-mismatched on read.
Using Metadata in Access Rules
A rule can read the metadata of the resource it's evaluating as md.self.<category>.<key>. These self-reads aren't declared — a category the rule references is inferred and loaded automatically, and one that doesn't exist reads as null. An explicit [metadata.self] categories = [...] on the config that owns the rule (a group type, collection type, or database type) is also supported and unions with the inferred set — reach for it when you want to load a category the rule doesn't name directly. The metadata read API's readRule plays no part here — a rule reads the resource's own metadata regardless of the caller:
# primitive/dev/group-type-configs/team.toml
[groupTypeConfig]
groupType = "team"
ruleSetName = "team-rules"# in the attached rule set
member.create = "md.self.config.tier == \"pro\""Groups and collections also expose a reserved, read-only attrs category projected from the resource's own fields — no schema, nothing to write, just reference it like any other category:
| Resource type | md.self.attrs.* |
|---|---|
group | groupType, groupId, name, createdBy |
collection | collectionId, collectionType, name, createdBy |
group.get = "md.self.attrs.createdBy == user.userId"Reading a Related Resource's Metadata
Beyond the subject's own metadata, a manifest can declare a path to another resource reached through a metadata key that holds its id, chaining up to 3 hops:
[metadata.self]
categories = ["system"]
[metadata.paths.school]
from = "self"
via = "system.schoolId"
type = "school"
categories = ["system"]# now usable in the rule:
md.school.system.tier == "pro"Declaring the Path on the Rule Itself
A manifest declared on the config applies to every rule in it. When only one rule needs a path, declare it on that rule instead: a rule-set operation may be a { expr, loads } table rather than a bare CEL string, and loads.paths takes the same shape as [metadata.paths.*] above.
# A rule-set entry, colocated with the rule that needs it. Operations that
# need no declaration stay bare strings in the same block.
[rules.member]
create = "true"
[rules.member.list]
expr = "md.caller.billing.status == 'active'"
[rules.member.list.loads.paths.caller]
rootFrom = "user.userId"
type = "user"
categories = ["billing"]Both forms feed the same resolver, so md.<name> binds identically either way — colocation is about keeping a one-rule declaration next to the rule that needs it, not a different capability. primitive config round-trips both spellings, and the two coexist in one category block.
Only loads.paths is accepted on a rule entry. loads.secrets and loads.vars stay config-level and are rejected here, following the declared-only rule in Access Control. An empty loads (or an empty loads.paths) collapses back to a bare expression.
The Caller's Own Metadata
A rule can also read the calling user's own metadata, independent of whose resource is being evaluated, by declaring a path rooted at user.userId — the one server-authenticated root (every other path root, such as record.*, is a value the request supplies, not an authenticated identity):
[metadata.paths.caller]
rootFrom = "user.userId"
type = "user"
categories = ["billing"]list = "md.caller.billing.status in ['trialing', 'active', 'past_due']"This is available to a group or collection rule set whose traversal path declares rootFrom = "user.userId" (as in the example above). When there's no authenticated caller, md.caller binds null, and a rule that dereferences it denies rather than erroring.
Writing Metadata from a Server Function
A server function reads, writes, and deletes a resource's metadata through ctx.api.resourceMetadata, and resolves a resource by a unique value the same way — on the app's own authority, gated once by the function's own access rule rather than by readRule/writeRule, which gate calls from your app's clients.
That makes a server-owned category a one-line rule: give it a writeRule no client satisfies, and write it only from the function that owns it — a webhook handler that records a payment provider's customer id, say. Clients can still read it through readRule.
# primitive/dev/metadata-category-configs/user.billing.toml
[metadataCategoryConfig]
resourceType = "user"
category = "billing"
writeRule = "false"
readRule = "true"
[metadataCategoryConfig.schema.fields.stripeCustomerId]
type = "string"
required = true
[metadataCategoryConfig.schema.fields.status]
type = "string"Stamping Metadata at Create Time
A collection can have metadata stamped in the same call that creates it, instead of a follow-up write. collections.create() accepts an optional initialMetadata: a map of category name → values. Each entry is schema-validated before the collection is created — an invalid entry fails the whole create — and the category's writeRule is waived for this initial stamp, since creation authority already covers it. Up to 10 categories per create.
const collection = await client.collections.create({
name: "Class 42",
collectionType: "class",
initialMetadata: {
settings: { visibility: "class-only" },
},
});let collection = try await client.collections.create(
params: CreateCollectionParams(
name: "Class 42",
collectionType: "class",
initialMetadata: ["settings": ["visibility": .string("class-only")]]
)
)A database takes the same map from the CLI, as --initial-metadata '<json>' on primitive databases create (and primitive collections create takes it too). On a collection, staging initialMetadata at create time can also gate the create itself — see Collections.
Metadata Lifecycle
Writing metadata doesn't check that the target resource exists — a write for a not-yet-created or already-deleted resource succeeds, and deleting a resource doesn't delete its metadata. This keeps writes cheap (no extra lookup) and lets a provisioning function write metadata immediately after — or interleaved with — creating the resource it belongs to.
Two patterns keep metadata consistent with the resources it describes:
- Gate writes with
writeRule. Restricting who can write a category limits who can create metadata for a resource that doesn't exist. - Clean up metadata wherever you delete the resource. Whatever flow deletes a resource should also delete its metadata — with
resourceMetadata.delete(client),primitive metadata delete(CLI), orctx.api.resourceMetadatain the server function that tears it down.
Two ordering rules apply when you tear down:
- Delete values before their category config. Once a category config is deleted, its stored values are orphaned and can no longer be deleted through any surface.
- Delete metadata before the owning resource when a
writeRulereadsresource.attrs.<column>. The rule loads the resource's own columns to authorize the delete, so it fails closed once the resource is gone.
Next Steps
- Access Control — The CEL identity context every rule shares
- Users and Groups — Group categories, and the projected
attrscategory - Collections — Collection categories, and the create-time metadata gate
- Server Functions — Reading and writing metadata on the app's own authority