The ontology
Each organization describes its own data, and its teams, as object types with typed properties and link types between them. Every write is checked against the current definition; new rules apply to stored data in the background, and deletions are instant. Access goes down to single objects and single properties.
The ontology is per organization, stored in tables scoped to the organization like everything else. It has four parts:
- Object types, such as
employeeorproject, each with a list of typed properties and format rules. Their definitions are versioned. Team types are object types whose objects are the organization’s teams, and timeseries types hold series of timestamped points, chat types channels of messages, and filesystem types a tree of folders and files instead of objects. - Objects: JSON property values of one type.
- Link types, such as
works_onfromemployeetoproject, with a cardinality and a name for each end. - Links between two objects.
Object types
An object type has an api_name ([a-z][a-z0-9_]{0,62}), a display_name
(1-200 characters), a description (up to 2000), and up to 200 properties.
{
"api_name": "employee",
"display_name": "Employee",
"properties": [
{ "api_name": "name", "type": { "kind": "string" }, "required": true,
"rules": [{ "rule": "length", "min": 1, "max": 100 }] },
{ "api_name": "email", "type": { "kind": "string" },
"rules": [{ "rule": "email" }, { "rule": "unique" }] },
{ "api_name": "age", "type": { "kind": "integer" }, "rules": [{ "rule": "min", "value": 16 }] },
{ "api_name": "manager", "type": { "kind": "user_ref" } },
{ "api_name": "tags", "type": { "kind": "array", "items": { "type": { "kind": "string" } } } }
]
}A property has an api_name, an optional display_name and description,
a type, required (default false), and rules. An integer or double
property may also have a unit (1-32 characters, such as "°C" or
"kWh"), a label for readers, not a rule. The server gives each
property and struct field an id that survives renames (see
Property ids).
Property types
kind | Values |
|---|---|
string | any JSON string |
integer | a JSON integer |
double | a JSON number |
boolean | true or false |
date | YYYY-MM-DD |
timestamp | RFC 3339; stored normalized to UTC |
geo_point | { "lat": -90..90, "lon": -180..180 } |
file_ref | the id of a file of this organization that the writer can read, checked on write |
user_ref | the id of a user of this organization, checked on write |
team_ref | the id of a team of this organization, checked on write |
array | { "kind": "array", "items": { "type": ..., "rules": [...] } }: elements of one type, each with its own rules |
struct | { "kind": "struct", "fields": [ properties... ] }: a nested object |
Arrays and structs nest at most 4 deep.
A reference to a user, team, or file that does not exist is a 422: “the
properties refer to users, teams or files that do not exist”. A file the
writer cannot read counts as one that does not exist, so a write can only
attach files its writer could already read.
Format rules
rule | Applies to | Meaning |
|---|---|---|
min {value}, max {value} | numbers | inclusive bounds |
length {min?, max?} | strings, arrays | characters of a string, elements of an array |
regex {pattern} | strings | Rust regex syntax, at most 1000 characters and size-limited when compiled; the whole string must match, so anchor it yourself |
enum {values} | strings, integers | one of up to 500 values |
email | strings | an email address (built-in) |
url | strings | an absolute http or https URL (built-in) |
uuid, cuid | strings | a UUID, a cuid2 (built-in) |
format {name, version} | strings, including nested values | one of the organization’s own formats |
unique | top-level scalar properties other than booleans | no two objects of the type share the value |
sequence | top-level integers, from the start | numbered by the server: 1, 2, 3, … (see Sequences) |
validator {name, version} | any type, including nested values | a custom validator |
A property may have up to 20 rules. Rules that do not fit their type (email
on an integer, an invalid regex) are refused with the definition, every
problem listed in a 422 validation_failed.
Sequences
A top-level integer property with {"rule": "sequence"} is numbered by the
server: invoice numbers, deal numbers, stock numbers.
{ "api_name": "number", "type": { "kind": "integer" }, "rules": [{ "rule": "sequence" }] }- Every create, through REST, MCP, or an action, gets the next number, 1, 2, 3, …, counted per organization, type, and property.
- Numbers are given out without gaps, in commit order. The counter stays locked from the create until its transaction commits, so creates of one type wait for each other for that moment, and a create that rolls back gives its number back. A deleted object’s number is not given out again.
- Writes may not set or change the number (
422). Within an action, the number is readable once the object is created, for example in an email as{{created.invoice.number}}. - The rule implies
unique. - It applies only to a property that has it from the start. Adding it to an existing property is refused; add a new property instead.
Team types
An object type of kind team ("kind": "team" when it is created; the kind
never changes) holds teams: each of its objects is a custom team of the
organization, with the object’s id as the team’s id. A team type needs a
required string property name, which names the team (1-100 characters,
unique across the organization), and may have any other properties, rules,
validators and actions:
{
"api_name": "squad",
"display_name": "Squad",
"kind": "team",
"properties": [
{ "api_name": "name", "type": { "kind": "string" }, "required": true },
{ "api_name": "lead", "type": { "kind": "user_ref" } },
{ "api_name": "oncall_phone", "type": { "kind": "string" } }
]
}Creating, renaming and deleting the object creates, renames and deletes the team, and the type’s access decides who sees a team and who changes its memberships. See Users, teams, permissions.
Objects
An object is the JSON map of its properties, plus metadata:
{
"id": "o4b8j2e6c0t1q5w9r3y7u2i6",
"type": "employee",
"properties": { "name": "Ada", "email": "ada@acme.example", "age": 36 },
"version": 1,
"etag": "q8XyZ3bKp1mWc0rT5vNa2g",
"validation": { "state": "valid", "rules_version": 3 },
"created_by": "h3jd8s0q2l5x7c1v9b4n6m8k",
"updated_by": "h3jd8s0q2l5x7c1v9b4n6m8k",
"created_at": "2026-10-24T10:00:00Z",
"updated_at": "2026-10-24T10:00:00Z"
}Every write is checked against the type’s current definition, which the
write holds FOR SHARE until it commits, so a schema change cannot slip in
between the check and the commit.
nullmeans absent. Required properties must be present.- Unknown properties are refused.
PATCHmerges the given properties into the stored ones (nullremoves one) and checks the result.expected_versionmakes a write conditional: if the object has moved on, the write is a409. It takes the object’sversionor itsetag.- A write that changes no value writes nothing: no new version, no event.
Versions are per reader
What a reader is shown of an object’s history comes from the properties
they can see. version counts the changes to those properties (and the
object’s creation), etag is a short hash of them, and updated_at,
updated_by and the validation state
reflect only them. So a change to a
property you cannot see changes nothing you
observe: not the version, the etag, updated_at or updated_by, the
validation state, the order of a $updated_at sort or a validation
filter, and no object.updated event reaches you.
expected_version and actions’ expected_versions compare with what the
writer sees, so a change to a hidden property never makes their write a
409. Prefer the etag. While you see the same properties, the version
and the etag agree. When your grants change, so does the set of properties
you see, and the version can jump or go backwards; the etag stays exact.
- A
uniquevalue another object of the type holds is a409naming the property.
A failed check is a 422 validation_failed with one entry per problem, each
with a path into the object and a message.
Versions and schema changes
Replacing a type’s definition (PUT /v1/ontology/object-types/{type}) bumps
its version, and every version is kept (GET .../versions).
A schema change never reads the stored objects and is never refused because of them. New rules apply at once to writes and in the background to stored objects:
- A type has a
rules_version, which goes up with every change to its properties except display names and descriptions. - Every object records the rules version it was last checked against. Its
validationis{"state": "valid"}or{"state": "invalid", "errors": [...]}(each error with apathand amessage) when that is the type’s current version, and{"state": "pending"}until the background job has checked it. - A write checks the current rules and marks the object valid. The job
checks pending objects in batches, against the newest rules version (a
change made while it runs supersedes the older one), and reports its
progress as
type.validationevents, at most once a second (less often on large types) while the job runs, and once when it is done with the type. - Invalid objects are kept as they are. Their next write must satisfy the rules.
- A type’s
validationsummarizes its objects against the current rules:pendingwhile some are unchecked, thenviolatedif some failed, orenforcedif all passed. The summary, and thetype.validationevents, are shown only to readers who see every property of the type. - An object’s state is per reader: problems with properties you cannot see
are never shown or counted, so an object whose only problem is hidden
from you is
validto you.
{ "rules_version": 3, "state": "violated", "objects": 1200, "validated": 1200, "invalid": 4 }Query {"op": "validation", "state": "invalid"} to find the objects to fix;
it filters by the state you are shown.
A unique value is held only by valid objects: an object that shares one
with another object becomes invalid, and is checked again once the other
object gives the value up. An object is marked valid only if it passes as
stored; anything the job cannot prove (a validator that ran out of time, a
user or file it refers to that is gone) makes it invalid.
A type that still has live objects or link types cannot be deleted.
Property ids
Every property and struct field has a server-assigned id (a cuid2) that
it keeps across renames, and stored values are keyed by it, not by name.
- Renaming. Send the property with its
idand a newapi_name. A property sent without anidmatches the existing one with the sameapi_name; anything unmatched is new and gets a fresh id. - Types are fixed. The type of an existing property never changes
(
422). To change it, remove the property and add a new one. - Removing is instant. A property left out of a
PUTdisappears at once from reads, queries, filters, MCP tools, and events, without the request reading or rewriting any object. A background maintenance job strips its stored values and unique entries later; that purge changes nothing the API shows. - Removed means gone. A property added later under a removed name gets a new id, so the old values never come back, even before they are purged.
{
"properties": [
{ "id": "p7r2o4p6e8r1t3y5i7d9x2c4", "api_name": "full_name", "type": { "kind": "string" }, "required": true }
]
}Here name is renamed to full_name, and every other property is removed.
Link types and links
A link type connects a source type (from_type) to a target type
(to_type), which may be the same type:
{
"api_name": "works_on",
"from_type": "employee",
"to_type": "project",
"cardinality": "many_to_many",
"forward_name": "projects",
"reverse_name": "members"
}- Cardinality is
one_to_one,one_to_many,many_to_one, ormany_to_many. It is enforced by partial unique indexes on a copy of the cardinality stored in each link, so the database refuses a link that breaks it (409). Changing a link type’s cardinality fails while visible links break the new one. - End names.
forward_namenames the link from the source,reverse_namefrom the target. End names are unique per object type, and a link from a type to itself needs two different names. - Links join objects of the declared types within one organization, enforced by composite foreign keys.
- A link is visible only while its link type and both its objects exist.
Team hierarchies
A link type between two team types may be a team hierarchy:
"team_hierarchy": "nested" or "inherited", set when it is created. Each
link puts its source team under its target team.
{
"api_name": "subteam_of",
"from_type": "squad",
"to_type": "squad",
"cardinality": "many_to_one",
"forward_name": "parent",
"reverse_name": "subteams",
"team_hierarchy": "inherited"
}Through inherited links, a subteam’s members count as members of its parent
and of every team above it, for every grant; nested links are structure
only. The hierarchy never has a cycle, through either kind (409), and
linking two teams needs edit access to both. See
Subteams.
Deletion
Deleting an object, a link type, or an object type is instant: it is gone from every read, query, filter, MCP tool, and event at once, and a background job removes the rows later without changing anything the API shows.
- The links of a deleted object or link type disappear with it, each with a
link.deletedevent, and no longer count for cardinality: a new link first removes the hidden links in its way. - A deleted object’s unique values are free at once, and so are a deleted type’s or link type’s names.
- A deleted object type is kept as a tombstone, so subscribers allowed to
see it receive its
type.deletedevent, until its events expire.
Queries
POST /v1/ontology/objects/{type}/query takes a filter tree, an ordering,
and a page size, and returns a page with a cursor for the next one:
{
"filter": {
"op": "and",
"filters": [
{ "op": "gte", "property": "age", "value": 30 },
{ "op": "contains", "property": "name", "value": "ad" },
{ "op": "linked", "link": "projects", "object": "o9k3m7p1s5v2x6z0b4d8f2h6" }
]
},
"order_by": { "property": "age", "direction": "desc" },
"limit": 50
}op | Fields | |
|---|---|---|
and, or | filters | |
not | filter | |
eq, ne, gt, gte, lt, lte | property, value | |
in | property, values | up to 500 values |
contains | property, value | strings: case-insensitive substring; arrays: has this element |
starts_with | property, value | strings |
is_null | property | the property is absent |
linked | link, object | linked to that object through this type’s end named link |
validation | state | valid, invalid, or pending against the type’s current rules |
Operands are checked against the property’s type and bound as SQL parameters; property names are checked against the type too. Filters have at most 100 conditions and nest at most 10 deep.
order_by is a scalar property, or $id, $created_at, $updated_at, with
direction asc (default) or desc; missing values sort last. Pagination
uses keyset cursors: pass next_cursor back as cursor. limit is 1-500,
default 50.
Access
Ontology access combines organization-wide permissions with per-type rules:
view_objectsreaches the objects of every unrestricted type;edit_objectslets you change them. Editing needs both, because editing includes viewing.- An owner can restrict a type (
PUT .../object-types/{type}/access). Then only owners and teams grantedview_objectsoredit_objectson that type reach its objects. To everyone else the type does not exist:404. - A restricted type can also limit a team to the objects shared with or assigned to each member; see Shared and assigned objects below.
- An owner can restrict single properties of any type, such as a salary or a purchase price, to some teams; see Restricted properties below.
- Teams here are effective teams: a grant to a team also reaches the members of its subteams along inherited links.
- Links to types you cannot view are hidden, and you cannot navigate them.
- Schema changes need
manage_ontologyand a view of the whole type, every object and every property, because whether a change is accepted says something about the stored values. A view of only the objects shared with you, or without a restricted property, is not enough:403. manage_ontologyalone cannot decide who reads data: access settings are owner-only.- OAuth grants (MCP clients) never change the schema.
- A team granted a business action may make that
action’s edits without
edit_objects, but still needs view access to every type the action touches.
Shared and assigned objects
A restricted type may name access properties: top-level properties of
type user_ref or team_ref, or arrays of either, such as an incident’s
assignee, a document’s owner, or its viewers and teams lists. A
view_objects grant on the type is then either for all of its objects
(scope: "all", the default) or only for the objects that name the member
(scope: "assigned"): those where one of the access properties holds the
member’s own user id, or the id of a team they are on, as the value or as an
element of the list.
PUT /v1/ontology/object-types/incident/access
{ "restricted": true,
"access_properties": ["assignee"],
"grants": [
{ "team_id": "<dispatch>", "permission": "view_objects" },
{ "team_id": "<dispatch>", "permission": "edit_objects" },
{ "team_id": "<supervisors>", "permission": "view_objects" },
{ "team_id": "<technicians>", "permission": "view_objects", "scope": "assigned" } ] }Here every technician sees only the incidents assigned to them; dispatch and supervisors see all of them. Owners always see every object.
The same mechanism shares objects. A document type with
"access_properties": ["owner", "editors", "viewers", "teams"] and an
assigned view grant to everyone lets each member see the documents they
own, the ones listing them as an editor or viewer, and the ones shared with
a team they are on. Sharing is a write to one of those properties, so who
may share is decided like any other write, usually by a
business action. See
Example: document management.
Postgres enforces this itself, with a second row level security policy on objects that can only narrow what the organization policy shows, for reads and for writes. So it holds everywhere the objects are reached:
- Reads. Gets, queries,
linkedfilters, link navigation, MCP tools, and actions see only the objects in reach. Any other object answers404, as if it did not exist; passed to an action, it is refused as an unknown object (422, atparams.<name>). - Files. Files that a visible object refers to through a
file_refcan be read with it, and stop being readable when the object is unshared. See Files. - Writes. An edit grant reaches what its holder can see. Sharing an
object further is allowed wherever you may edit it. A write that would
leave an object out of its writer’s sight is
403 forbidden(“you may only write objects that stay shared with you”): unsharing it from yourself, handing your incident to someone else, or creating one only for someone else. Hidden objects are never changed. - Teams. Leaving a team ends access to what was shared with that team at once; joining one starts it.
- Links. Deleting an object linked to objects you cannot see is
409; removing a link to one is404. - Events. For a type you see only in part, an event reaches you only if
you see every object it names when the event is read, and the type’s
type.validationprogress events never do. An object shared with you starts sending you its events. - Removing a property. If a schema change removes an access property, it
is also removed from the type’s
access_properties; while none are left,assignedgrants reach nothing.
Two things can still hint at hidden objects: a 409 for a unique value or
a link’s cardinality can reveal that an object you cannot see holds it, and
a type’s validation counts cover only the objects you see.
Restricted properties
Owners may restrict top-level properties of any type, restricted or not, and
grant teams view_objects or edit_objects on each one. A restricted
property is shown to owners and to the members of the teams granted either
permission on it, and written by owners and the edit_objects grantees, who
also need edit_objects on the type. Properties that are not restricted
follow the type.
PUT /v1/ontology/object-types/employee/access
{ "restricted": false,
"properties": [
{ "property": "salary",
"grants": [
{ "team_id": "<hr>", "permission": "edit_objects" },
{ "team_id": "<finance>", "permission": "view_objects" } ] } ] }Here HR sees and changes salaries, finance sees them, and everyone else who sees employees sees them without one. The restriction goes by the property’s id, so renaming the property keeps it.
For everyone else, a restricted property does not exist:
- Reads. It is absent from objects in every response, MCP tools,
linkednavigation and live events. Event payloads are stripped for each subscriber when the event is read. - Definitions. It is absent from
GET /v1/ontologyand the type. A property you see but may not write is listed in the type’sread_only_properties. - Queries. Filters and sorts on it are refused as on an unknown property.
- Files. A file readable only through it is not readable.
- Writes. A write naming it is refused exactly as one naming a property that does not exist. A read-only property may be sent back unchanged. Every write keeps the hidden values as they are stored.
- Errors. Validation errors, unique conflicts and missing references about hidden values are reported without their path, value or id.
Actions read and write whole objects: their grant
stands in for property access, as it does for edit_objects, and their
results name ids only. A team type’s name cannot be restricted.
An object’s version, etag, updated_at, updated_by and validation
state come from the properties you see, so a hidden value changing or failing
a rule shows nowhere; see Versions are per reader.
Live updates
Every change to the ontology is also an event in the organization’s outbox, streamed as server-sent events. See Live events.
Taken together: every live object marked valid at its type’s current rules version satisfies those rules (so an enforced type’s objects all do), every link joins objects of its declared types and respects its cardinality, and unique values stay unique among valid objects. Links of deleted objects and link types are never visible, and the background job changes nothing the API shows except validation marks.