Ontology design¶
English · 日本語
Explanation — This page explains how to think about ontology boundaries and security before you build, and Getting started and the API reference show the code.
This guide is for people and coding agents deciding what an ontology should mean
before they declare it with Ontology and OntologyObject, or generate the
lower-level ObjectTypeDef, PropertyDef, LinkTypeDef, ActionTypeDef, and
FunctionDef descriptors. It is a design guide, not a second API reference: use it
to choose boundaries, ownership, relationships, behavior, names, and security.
ontary validate reports the mistakes in this guide that a tool can detect, and each
finding links back to its section (see the CLI reference).
It is an original, ontary-native distillation of Palantir Foundry's Best practices, Structural guidance, and Anti-patterns. Foundry's Workshop, Object Views, and MDOs are application or platform concepts and are out of scope for an SDK; ontary has no corresponding authoring constructs.
A useful ontology does more than rename tables. It gives stable identities to business objects, makes relationships explicit, exposes governed business operations, derives answers through guarded reads, and declares which facts each consumer may see. The result should still make sense if the source vendor, current department chart, or user interface changes.
Core principles¶
Domain-driven design¶
Start with the language and decisions of the domain, not the shape of an export.
An ObjectTypeDef should represent a business concept with a stable identity; a
PropertyDef should represent a fact about that concept; a LinkTypeDef should
name a relationship; an ActionTypeDef should express an allowed business
transition; and a FunctionDef should answer a derived question.
Work outward from real workflows. Identify the nouns people distinguish, the verbs they are authorized to perform, the invariants those verbs protect, and the questions users repeatedly calculate. In the tickets example, Org, Queue, Ticket, and Comment are separate concepts because they have different identities and lifecycles. A ticket-escalation action names a domain outcome rather than a storage edit.
Keep physical integration behind that model. A source column may inform a property, but it should not dictate an object boundary or public name.
Source: Palantir, "Ontology design: Best practices".
Don't repeat yourself (rule of three)¶
Store one authoritative fact in one place and reach it through links or derive it
with a FunctionDef. Two similar shapes can remain separate while their meaning is
still emerging. On the third repetition, decide whether the shared idea is a real
object, a shared naming convention, or merely common implementation code. Abstracting
earlier often freezes an accidental similarity into the ontology.
DRY applies to meaning, not just field spelling. Copying a person's region onto every related record creates several answers to the same question even if all properties have the same type. Conversely, two properties named status need not be unified if they describe different lifecycles. Use domain identity and ownership, not textual similarity, to decide.
Source: Palantir, "Ontology design: Best practices".
Open for extension, closed for modification¶
Prefer designs that accept a new workflow without changing the meaning of existing
types. New ObjectTypeDef, LinkTypeDef, ActionTypeDef, and FunctionDef
declarations can extend a stable core. Existing API names, property semantics, link
directions, and action outcomes are contracts for human applications and agents.
When an existing object shape genuinely evolves, change the same type deliberately and keep its history in the store's own row history. Do not pre-build speculative extension points, and do not silently reinterpret an old property to accommodate a new use case.
Source: Palantir, "Ontology design: Best practices".
Composition over deep hierarchies¶
Compose domain concepts with LinkTypeDef relationships rather than inventing a
deep inheritance tree. A link makes both endpoint identities, its direction, and its
Cardinality reviewable. It can also carry its own authority declaration through
owned.
Inheritance is an implementation technique for Python classes; it is not a substitute
for domain relationships. Prefer small object types that can participate in several
links. When several types need a common derived question, a FunctionDef can read
across them without claiming that they share one ontological parent.
Source: Palantir, "Ontology design: Best practices".
Structural guidance¶
Normalization and derived values¶
Record each fact at the boundary that owns it. Link to that fact from elsewhere
instead of copying it into every object that needs it. In particular, store inputs
once and calculate scores, rollups, classifications, and other derived answers with
FunctionDef; Functions use guarded reads and do not write. The tickets example's
ticket statistics Function derives aggregates
from Ticket facts rather than persisting competing totals.
A declared point-in-time snapshot is the one intentional exception. If the domain
must preserve what a score or decision was at a named instant, model a snapshot
object explicitly, including its subject and observation time, and declare it with
snapshot=True on @ontology.object. Do not call an
ordinary cache, report result, or duplicated current value a snapshot. Routine
history needs no clone: stored rows already carry valid_from and valid_to.
Normalize semantic facts, not blindly every physical value. A property that is part of an object's own state can stay on that object. Split it out when it has an independent identity or lifecycle, many participants, distinct ownership, or security that cannot be expressed cleanly on the containing object.
Source: Palantir, "Ontology design: Structural guidance".
Structs¶
Use a struct for a small, inseparable value that is always created, secured, and
changed as a whole with its parent. For example, an order can declare amount: Money:
class Money(BaseModel):
value: float
currency: str
class Order(OntologyObject):
amount: Money
The inner fields remain part of the ontology declaration, while sensitivity applies to the whole value. Struct inner fields may only default to None; set other values at the call site.
Use a linked object type with a LinkTypeDef when the group is repeatable, shared,
independently governed, or an action target. These cases need their own identity,
lifecycle, ownership, or action boundary.
Structs are flat. Filtering, ordering, and grouping on the struct property are
unsupported; numeric aggregation is also unsupported, though count remains
available. Querying by an inner field such as amount.currency is not supported.
Source: Palantir, "Ontology design: Structural guidance".
Choice properties¶
A fact that takes one of a fixed set of values, such as a ticket's status, is a
choice property: annotate it with a string Enum or a Literal. The PropertyDef then
declares the allowed values, and every write path enforces them. Do not accept free
text and check the value inside each action.
A choice is a value, not an entity. When the values need their own attributes,
lifecycle, or security, or people add new values as part of the operation, model
them as an ObjectTypeDef and link to it with a LinkTypeDef instead. Changing the
set of choices is a schema change. Removing a value leaves stored rows that the
narrower declaration refuses on read, so plan that change like any other migration.
Rules and status transitions¶
Use a transition graph for the allowed moves of one choice property. Use a rule for an invariant that can be decided from one object, and use an action precondition for an operation-specific condition such as permission, request context, or another object. A cross-object check stays in the action; a rule is not a place to look up other objects.
from enum import StrEnum
from ontary import Ontology, OntologyObject, prop
from ontary.meta import TransitionDef
_ontology = Ontology("orders", scope_levels=["team"])
class OrderStatus(StrEnum):
PENDING = "pending"
PAID = "paid"
SHIPPED = "shipped"
@_ontology.object(layer="L0", scope="unscoped")
class Order(OntologyObject):
id: str = prop(primary_key=True)
status: OrderStatus = prop(transitions=TransitionDef(
initial=("pending",),
moves={"pending": ("paid",), "paid": ("shipped",), "shipped": ()},
))
paid: bool = False
@_ontology.rule(Order, "shipped_needs_payment", message="payment is required")
def shipped_needs_payment(order: Order) -> bool:
return order.status != OrderStatus.SHIPPED or order.paid
Keep each rule pure: the predicate should decide from the typed object it receives and should not write data or consult external state. It receives the full new object with every declared property, including properties restricted from consumers. The engine does not enforce purity. A failed rule refuses the write, and the refusal includes the rule name and its message.
The initial states apply to objects created through an action. Ingest and
direct store inserts may start at any declared state. An ingest update of an
existing row must still follow the transition graph; it cannot move that row to
a state the graph does not allow.
Events¶
An event is a business fact: "the order shipped". Readers see it under the same object security as the subject it is about. Audit is a different record. It is the administrative who-did-what trail of the engine, and an event is what happened. Record the fact as an event; do not read the audit log to learn it.
Name an event in the past tense, because it states something that already
happened: OrderShipped, not ShipOrder. The imperative name belongs to the
action that causes it.
Do not declare an *Event object type to hold facts. That stores the same fact
twice and escapes the engine's visibility rules. Declare an Event and let an
action emit it with emits=.
An event is about the action's target. Pass about= to ctx.emit when the action
has no target id (a creating action) or when the fact concerns another object of
the action's target type that the context handed out. Events share the retention
of the audit row they are stored on: they commit or roll back with the invocation
and live as long as that row.
from typing import Any
from ontary import ActionContext, ActionParams, Event, Ontology, OntologyObject, prop, target
_ontology = Ontology("orders", scope_levels=["team"])
@_ontology.object(layer="L0", scope="unscoped")
class Order(OntologyObject):
id: str = prop(primary_key=True)
@_ontology.event(description="An order has shipped.")
class OrderShipped(Event):
carrier: str
class ShipOrder(ActionParams):
order_id: str = target(Order)
carrier: str
@_ontology.action(ShipOrder, target=Order, roles=["ops"], emits=[OrderShipped])
def ship(ctx: ActionContext, p: ShipOrder) -> dict[str, Any]:
ctx.get(Order, p.order_id)
ctx.emit(OrderShipped(carrier=p.carrier))
return {}
Interfaces¶
Foundry interfaces define a common contract across object types. No equivalent
yet; the nearest approximation is shared property conventions plus Functions over
multiple types. There is no ontary interface declaration and no promise that two
ObjectTypeDef instances are substitutable.
If several types expose the same concept, give the relevant PropertyDef
declarations the same meaning and naming, document that convention, and validate it
in the ontology's own tests. Use a FunctionDef when consumers need one derived
operation over multiple types. If shared identity and lifecycle emerge, reconsider
whether the types should instead link to one common object.
Source: Palantir, "Ontology design: Structural guidance".
Links and object-backed link types¶
Use LinkTypeDef for a relationship whose meaning is captured by its endpoints,
direction, Cardinality, and governance flags. Name the relationship from the
domain, validate both endpoints, and declare owned when an action rather than a
source creates the link. A domain example can use links to distinguish ordinary
containment, identity-revealing traversal, and action-owned relationships.
Foundry object-backed link types attach properties to a relationship. No
equivalent yet; LinkTypeDef carries no payload properties, and the nearest
approximation is an explicit relationship ObjectTypeDef connected to each
participant by a LinkTypeDef. Use that shape for facts such as role, effective
date, rank, or provenance that belong to the relationship itself. The relationship
object also becomes the correct target when its lifecycle needs an ActionTypeDef.
Do not encode the same association independently as an unconstrained foreign-key property and a link unless the property is required for scope or contributor resolution. If both are necessary, treat them as one invariant and populate them together.
Source: Palantir, "Ontology design: Structural guidance".
Naming conventions¶
Use singular business nouns for object types, specific relationship phrases for links, business verbs for actions, and question-like or result-oriented names for Functions. Names should make sense to a domain expert without knowing a database schema, vendor API, team acronym, or current UI.
Treat public API names as durable identifiers. Keep display text and descriptions clear enough for an agent to choose the right operation, but do not overload one name with several meanings. Use consistent property names only where the underlying semantics are consistent. Avoid implementation suffixes such as V2, table prefixes, and temporary project labels.
Source: Palantir, "Ontology design: Structural guidance".
Retirement and removal¶
Removal is a business verb. Model it as an ActionTypeDef named for the business
outcome, such as OffboardEmployee or CancelSubscription; never name the action
DeleteEmployee or RemoveEmployee. Inside the declared action's handler, call the
engine primitives ActionContext.retire and ActionContext.unlink to perform the
transition. The action name stays in domain language, and there is no generic
delete/remove action to expose.
Store.retire_object closes the current row by setting valid_to; it is not a delete.
Lineage, history, and audit survive. A retired object is absent from current reads but
remains present in history. ActionContext.retire also cascade-closes every live link
that references the object on the side its link type declares for that object type, in
the same transaction.
Erasure is not an ontology concept, and this SDK gives you no erasure verb. Retirement
is the lifecycle verb: do not declare an Erase* or Delete* action, Function, client
operation, or MCP tool to serve a right-to-erasure request under GDPR/APPI. Destroying
stored bytes is an operational matter for whoever runs the database, decided outside the
declared ontology and separate from the business action that retires an object. Audit
stays the engine's AuditEntry; never declare a type or an action to record it.
A governed action may retire an object only when its whole type declares owned=True,
and may close a link only when that link type declares owned=True. Partial ownership
(an owned property map) is not enough. Source-backed data can never be retired or
closed by an action; the attempt raises the authority refusal with code
UNDECLARED_SOURCE_REMOVAL. If a retirement cascade meets a source-backed link, the
whole action transaction rolls back—there is no partial cascade.
Security design¶
Security is part of the semantic model, not a filter added by each application.
Design the ScopePolicy with the object and link graph: its row_visibility rules
decide whether a whole record exists for a consumer, while min_n prevents an
aggregate from describing too few distinct contributors. A failed aggregate raises
VisibilityError with code MIN_N_VIOLATION; an invisible record or forbidden
sensitive operation raises VisibilityError with code VISIBILITY_DENIED.
Declare field semantics on PropertyDef: scope_level participates in scope
resolution, and sensitivity holds a Sensitivity policy. Use ai_usable to hide
data from an AI consumer and human_visible to hide data from a human consumer.
These are different decisions, not a single confidentiality flag. Restricted
properties must remain optional so redaction can return no value.
Test the combinations, not just each declaration alone. In an HR domain, a policy
might hide candidate email from AI, hide demographic and identity fields from
humans, remove confidential rows, and count distinct candidates for min-N. That is
the intended shape: one
ScopePolicy enforced for every consumer, with semantic redaction by consumer kind.
Source: Palantir, "Ontology design: Structural guidance".
Anti-patterns¶
System Silos¶
Anti-pattern: shaping the ontology around one vendor, connector, or source system, so its table names, identifiers, and quirks become the public domain model.
Why it fails: replacing a vendor becomes an ontology migration; consumers learn integration details; and the same real-world entity arrives as several source-specific objects with no stable identity.
In ontary: keep the ontology vendor-independent. Name ObjectTypeDef and
LinkTypeDef from the domain, never from an extract. Load source data through
OntologyClient.ingest, which maps each incoming record onto those declared types
rather than letting the extract's own shape through. Use Source to preserve
lineage, and use owned declarations to keep source-backed facts separate from
ontology-owned state.
Source: Palantir, "Ontology design: Anti-patterns".
The Kitchen Sink¶
Anti-pattern: putting every available column and every possible concern onto one object type because the source can produce them.
Why it fails: most properties become optional or ambiguous, unrelated lifecycles collide, security becomes coarse, and every consumer must understand a sprawling shape to use a small part of it.
In ontary: keep an ObjectTypeDef cohesive around one identity and lifecycle.
Move independently governed or repeated concepts to their own object types and join
them with LinkTypeDef. Keep PropertyDef declarations that are genuinely facts of
the object, and derive consumer-specific answers with FunctionDef rather than
adding report-shaped fields.
Source: Palantir, "Ontology design: Anti-patterns".
Department Silos¶
Anti-pattern: declaring separate versions of the same business entity for each department, workflow, or access group.
Why it fails: identity and facts drift, cross-department links require matching logic, and organizational changes force schema changes even though the underlying domain did not change.
In ontary: declare one ObjectTypeDef for one domain identity and connect
department-specific process objects with LinkTypeDef. Govern who can see the
shared entity through ScopePolicy, PropertyDef sensitivity, and
row_visibility, not by duplicating it. Put a department's distinct behavior in
appropriately scoped ActionTypeDef declarations.
Source: Palantir, "Ontology design: Anti-patterns".
The God Object¶
Anti-pattern: making one central object own nearly every property, link, action, and lifecycle in the domain.
Why it fails: unrelated changes contend on one schema, cardinalities become implicit collections of fields, permissions spread across exceptions, and a change for one workflow risks every consumer of the object.
In ontary: separate concepts with stable identities into focused
ObjectTypeDef declarations and compose them through explicit LinkTypeDef
relationships with reviewed Cardinality. Target each ActionTypeDef at the object
whose lifecycle it changes. Keep the graph connected, but do not confuse connection
with ownership by one root object.
Source: Palantir, "Ontology design: Anti-patterns".
The Golden Hammer¶
Anti-pattern: representing every concern as another ontology object type, including logs, derived metrics, runtime metadata, and infrastructure mechanisms.
Why it fails: the graph fills with implementation artifacts, consumers cannot distinguish domain state from engine state, and duplicated platform records acquire their own inconsistent lifecycle and security rules.
In ontary: choose the construct that matches the concern: PropertyDef for a
fact, LinkTypeDef for a relationship, FunctionDef for a derived answer, and
ActionTypeDef for a governed transition. Audit is the engine's append-only
AuditEntry; never declare an audit object type to mirror it. Keep Workshop, Object
Views, and MDOs out of the ontology because they are application/platform concerns,
not SDK domain constructs.
Source: Palantir, "Ontology design: Anti-patterns".
Action Sprawl¶
Anti-pattern: generating a setter or CRUD action for every mutable property and calling that an operational ontology.
Why it fails: callers must reconstruct workflows from low-level edits, preconditions and permissions drift across setters, partial updates become possible, and audit records describe storage mechanics instead of business intent.
In ontary: declare a small ActionTypeDef set named with business verbs and
aligned to real outcomes. One action should own the complete invariant-preserving
transition, including its target, permitted roles, declared capabilities, and
ontology-owned writes. Never expose generic create, update, or set-property
actions. The tickets example's ticket-escalation action
escalates a ticket; its name tells a reviewer what happened.
Source: Palantir, "Ontology design: Anti-patterns".
The Time Machine¶
Anti-pattern: modeling history as separate objects or object types —
Survey2024, SurveyResponseV2, an *History clone per entity.
Why it fails: every consumer must know which version to query; links fan out across clones; "current state" becomes a convention instead of a query.
In ontary: declare one object type; the store keeps row history
(valid_from/valid_to, close-old-insert-new), so history is a storage concern,
not a modeling problem. If a point-in-time value must be first-class, declare it
explicitly as a snapshot type (e.g. EngagementScoreSnapshot) — the only sanctioned
duplicate of a derivable fact. History is row history, never a V2 type.
@ontology.object(layer="L0", snapshot=True)
class EngagementScoreSnapshot(OntologyObject):
id: str = prop(primary_key=True)
Source: Palantir, "Ontology design: Anti-patterns".
The Misnomer¶
Anti-pattern: using a familiar but inaccurate business word, a source-system label, or a vague technical name for an object, link, action, or property.
Why it fails: different consumers attach different meanings to the same declaration, agents select the wrong operation from its description, and later authors compensate with aliases and exceptions instead of fixing the model.
In ontary: make each ObjectTypeDef, LinkTypeDef, ActionTypeDef, and
FunctionDef API name state one precise domain meaning. Use descriptions to record
boundaries and invariants, and use business verbs for actions. If a name is wrong,
plan an explicit schema and consumer migration; do not create a misleading V2 twin
or silently change what the old name means.
Source: Palantir, "Ontology design: Anti-patterns".