Skip to content

ontary — API reference

English · 日本語 · ← README

Reference — This page lists the public names of ontary for lookup, and Getting started and Testing your ontology show them in use.

The curated front door of ontary: 46 names in __all__. The rest of the engine remains available from its canonical submodule (ontary.meta, ontary.store, and so on).

This is a lookup document. For the narrative walkthrough — author, declare, bind, read, serve — start with the README.

Contents


Front door

__all__ is sorted, duplicate-free, importable, and exactly 46 names. These are the names an ontology author should reach for without choosing an engine namespace.

Authoring vocabulary

ActionContext, ActionParams, BoundQuery, CapabilityHandle, Cardinality, Consumer, DirectProperty, CustomResolver, Event, FunctionParams, LinkHandle, Ontology, OntologyObject, RowVisibilityStore, SelfScope, Sensitivity, Source, Store, ViaLink, prop, ref, scope_ref, target.

Runtime entries

Declarations, EventRecord, Finding, InMemoryStore, ObjectStore, MCPServer, OntologyClient, Page, PostgresStore, ScopePolicy, TypedPage, __version__, build_mcp_server, and declarations.

MCPServer is the mcp SDK's server class, re-exported because the MCP builders return it; it needs the [mcp] extra (pip install 'ontary[mcp]'). import ontary works without the extra, and only touching MCPServer raises an ImportError naming the install command. In a core-only install that includes from ontary import *, which fetches every __all__ name.

Error classes

ActionError, AuthorityError, ConflictError, InternalError, OntaryError, PermissionDenied, PreconditionFailed, ValidationFailed, and VisibilityError.

The 46-name count is asserted exactly by tests/test_docs.py, so a new root export cannot quietly expand this vocabulary.

from ontary import Ontology, OntologyObject, Consumer, prop, target, Cardinality

Python 3.12+. The core package depends only on pydantic. Extras: [mcp] (MCP server), [postgres] (the PostgresStore backend).


Engine surface

Names below are intentionally not flattened into the front door. Import them from the defining submodule when extending the engine or using an advanced integration.

ontary.actions

ActionExecutor.

ontary.audit

CapabilityAccessRecord.

ontary.client

OntologyRuntime.

ontary.errors

ERROR_CODES, ErrorCodeInfo, Kind.

ontary.functions

FunctionHandler, FunctionRegistry.

ontary.ingest

IngestError, IngestReport, bulk_link, bulk_upsert.

ontary.mcp_server

ConsumerResolver, build_multi_consumer_mcp_server.

ontary.meta

ActionParameterDef, ActionTypeDef, FunctionDef, LinkTypeDef, ObjectTypeDef, OntologyRegistry, PropertyDef, StructFieldDef, TransitionDef, RuleDef, PropertyType, ScopeLevel.

ontary.ontology

OntologyDef.

ontary.query

GuardedQuery.

ontary.scope

Direction, RowVisibilityFn, ScopeRule, resolve_contributor, resolve_owning_scope.

ontary.security

ConsumerKind, covers_scope.

ontary.store

AuditEntry, DEFAULT_BATCH, DEFAULT_TENANT, Lineage, SCHEMA_VERSION, StoredObject, WriteRecord.

ontary.testing

Public SDK-user test helpers are make_store, consumer, raises_code, FixedClock, SequentialIds, Scenario, and scenario. make_store(ontology) creates a fresh InMemoryStore; consumer(...) builds a valid Consumer; and raises_code(code) asserts a raised error by its machine-readable code — any OntaryError, including ontary.ingest.IngestError, as well as structurally compatible author-defined coded exceptions that expose a stable string .code. FixedClock(start) returns the same timezone-aware datetime on every call and rejects a naive start. SequentialIds(prefix) returns deterministic IDs prefix-1, prefix-2, and so on.

scenario(ontology, *, store=None, clock=None, id_factory=None, capabilities=None) returns a Scenario for eager, chainable tests. It binds the store, clock, and id factory once, before any seed write. Defaults are a fresh InMemoryStore, FixedClock at 2026-01-01T00:00:00Z, and SequentialIds("id"). Pass an empty store when overriding. Every method returns the same Scenario:

Method Meaning
given(*objects) Seeds typed OntologyObject instances. Allowed only before the first when. A store refusal is re-raised as AssertionError naming the object and the error code.
given_link(handle, from_, to) Seeds a link through a typed LinkHandle. Endpoints are objects or id strings. Allowed only before the first when.
when(params, *, by) Runs one ActionParams through the governed runtime as consumer by (required). Raises AssertionError first if the previous step failed and no then_error checked it.
then(cls, pk, **fields) Requires the last step to have succeeded. Compares each named property with == against the unredacted current row. An undeclared field raises INVALID_RECORD.
then_result(expected) Requires success and that the return value equals expected.
then_error(code) Requires failure with code, and that current objects and links equal the state before the step. Only this check marks the failure as checked.
then_absent(cls, pk) Requires that no current row exists. Works after success or failure.
then_link(handle, from_, to) Requires that the link exists in the current state.
then_no_link(handle, from_, to) Requires that the link does not exist in the current state.

Failed checks raise AssertionError. Every scenario ends in a then* call; a scenario that ends in a when checks nothing.


Authoring an ontology

Ontology(name, scope_levels, min_n=3)

The authoring facade. Accumulates an OntologyRegistry, a ScopePolicy, and the declared handlers, then hands out runtimes.

Parameter Type Notes
name str Also the default MCP server name.
scope_levels list[str] Your own hierarchy, coarsest last — e.g. ["queue", "org"]. There are no built-in levels.
min_n int = 3 Minimum distinct contributors for any aggregate.

An empty or duplicated scope_levels, or min_n below 1, is refused with ValidationFailed (ONTOLOGY_INVALID) when .definition is first built.

Declaration methods, all decorators except link:

Method Purpose
@ontology.object(...) Register an OntologyObject subclass as an object type
ontology.link(api_name, from_cls, to_cls, cardinality, ...) Register a link type; returns a LinkHandle
@ontology.action(params_cls, ...) Register a typed action handler
@ontology.function(...) Register a derived-value function
ontology.capability(proto, ...) Declare a capability; returns a CapabilityHandle
ontology.validate(store=None) Validate and freeze registration; with a store, also refuse stored rows that no longer hydrate
ontology.diagnose(store=None) Return every Finding without raising or freezing; with a store, also sweep its rows
ontology.bind(store, ...) Build an OntologyRuntime

validate() (and touching .definition) freezes the ontology — any later object/link/action/function call raises. Call it once, after every declaration.

Checks that teach. Two modelling mistakes that used to pass validate() and fail later are now caught where they are written:

  • An action whose target(...) parameters all refer to another type than its target= is refused at @ontology.action(...) with ONTOLOGY_INVALID, naming the action, the parameters, and both types. Before, the scope gate checked the parameter's object while the audit entry and MCP named target=. One target() parameter must refer to the target= type; extra target() parameters of other types are allowed. validate() and diagnose() apply the same rule to a hand-built OntologyRegistry.
  • Rows written before an ontology edit (a property made required, a changed type, narrowed choices) fail only when first read, because a store records no fingerprint of the ontology. diagnose(store=store) reads every current row with Store.read_all and returns one INVALID_RECORD finding per type and property, with the number of rows that would fail hydration. validate(store=store) raises INVALID_RECORD for them. The sweep reads every row, so it runs only when you pass a store, never at bind().
ontology.validate(store=store)  # before serving an edited ontology

@ontology.object(*, layer, owned=False, api_name=None, description=None, display_name=None, scope: Literal["unscoped"] | Sequence[ScopeRule] | None = None, contributor: Sequence[ScopeRule] | None = None, row_visibility: RowVisibilityFn | None = None)

Registers the decorated OntologyObject subclass.

  • layer — your own grouping string ("L0", "core", whatever). No engine behavior attaches to it; it is carried through to MCP introspection (list_object_types) so agents and tooling can see how you grouped things.
  • owned — True marks the whole type ontology-owned (no source may write it); a dict marks specific properties owned with their defaults, e.g. owned={"escalated": False}.
  • scope — "unscoped", or a list of scope rules (see Scope policy). Resolution runs per declared level: for each level, rules are tried in declaration order and the first that both targets that level and resolves to a value wins.
  • contributor — scope-rule list identifying the person behind a row, used for min-N counting.
  • row_visibility — (store, consumer, obj_id, payload) -> bool, an extra per-row gate applied on top of scope.
  • accept — a lint code or a sequence of codes (FORBIDDEN_TYPE_NAME, AUDIT_TYPE) this type accepts. snapshot — True declares a snapshot type. Both are described in Advisory findings.

scope, contributor, and row_visibility are shape-checked when the class is decorated. A misspelled "unscoped", a single rule not wrapped in a list, or a non-callable row_visibility raises ValidationFailed (ONTOLOGY_INVALID) right there, naming the class, the kwarg, what was given, and the accepted forms. The Literal annotation makes the misspelled string a mypy error as well.

ontology.link(api_name, from_cls, to_cls, cardinality: Cardinality | Literal["ONE_TO_ONE", "ONE_TO_MANY", "MANY_TO_ONE", "MANY_TO_MANY"], *, description=None, identity_revealing=False, owned=False) -> LinkHandle

cardinality is a Cardinality enum member or its string name: ONE_TO_ONE, ONE_TO_MANY, MANY_TO_ONE, MANY_TO_MANY. Any other string is refused with ValidationFailed (ONTOLOGY_INVALID) listing those four names, and the Literal annotation makes the typo a mypy error as well.

identity_revealing=True means a human consumer is refused the traversal outright (AI consumers may still follow it). The returned LinkHandle is what you pass as the first argument to client.traverse(link_cls, from_obj_or_id) for a typed result.

OntologyObject

Base class for declared object types — a pydantic.BaseModel subclass, so validators, computed fields, and plain annotated attributes all work normally.

Two attributes are attached by the engine on a read, and are not model fields (so they never collide with a property you declared):

Attribute Type Meaning
lineage Lineage \| None Where the row came from and its validity window
redacted_fields frozenset[str] Properties blanked for this consumer

redacted_fields is what distinguishes "hidden from you" from "genuinely stored as None" — a visible-but-absent optional field is None too, but never appears here.

prop(*, primary_key=False, sensitivity=None, scope_level=None, required=None, property_type=None, choices=None, transitions=None, **field_kwargs)

pydantic.Field(...) plus ontology metadata. Unrecognized kwargs pass straight through to Field, so prop(default=None, description="...") behaves as expected. prop() is never a parallel field system: bare annotated fields and plain Field(...) keep working on the same class.

A property with restricted sensitivity must be declared X | None. The decorator raises a coded validation error at class-registration time otherwise — because a redacted read has to be able to return None for it.

choices=["open", "closed"] limits a str property to those values. Every write path refuses any other value.

accept="STORED_DERIVABLE" (or "FREE_TEXT_STATUS", or a sequence of them) accepts that advisory finding for this property. See Advisory findings.

transitions=TransitionDef(initial=(...), moves={...}) declares allowed moves on a choice property. String values and string-valued Enum members (plain Enum or StrEnum) are accepted as states. A primary key cannot have transitions.

Use prop(transitions=...) to attach the graph, and import TransitionDef from ontary.meta; it is not exported from ontary.__all__.

Ontology.rule(cls, name, *, message)

Decorate a typed predicate after registering cls with @ontology.object:

@ontology.rule(Order, "shipped_needs_payment", message="payment required")
def shipped_needs_payment(order: Order) -> bool:
    return order.status != OrderStatus.SHIPPED or order.paid_at is not None

The predicate receives a hydrated object built from the full stored row and must read only that object. Its name and message appear in the type declaration; the callable is omitted from exported schema data. Rules can be registered until ontology.definition freezes the ontology.

Choice properties: Enum and Literal

Annotate a property with a string-valued Enum (for example a StrEnum) or a Literal[...] of strings. It declares the same shape as str plus choices: the PropertyDef has type="str", and its choices are the member values in declaration order.

class Status(StrEnum):
    OPEN = "open"
    CLOSED = "closed"


@ontology.object(layer="L0", scope="unscoped")
class Ticket(OntologyObject):
    id: str = prop(primary_key=True)
    status: Status
    kind: Literal["bug", "task"]
  • Storage. The store keeps the member's string value. Every write path accepts an Enum member or its value: ingest, ctx.create, ctx.save, action parameters, and where filters. Any other value is refused (INVALID_RECORD on a write, INVALID_PARAMS on an action). A where filter does not refuse a value outside the choices; it matches no rows.
  • Only where choices are declared. An Enum member is unwrapped to its value by the declaration, not by its Python type. A prop(choices=...) property accepts a member whose value is one of its choices, because it is the same declaration. A plain str property without choices still refuses an Enum member (INVALID_RECORD on a write, OPERATOR_TYPE_MISMATCH in a where filter), as it did before.
  • Reads. A typed read returns the Enum member, or the Literal string, so mypy narrows the field. A dict read, an MCP result, and an aggregate_by key return the plain string.
  • Action parameters. The same annotations on an ActionParams field set choices on its ActionParameterDef, and the typed handler receives the member. prop(choices=[...]) on a str parameter sets choices too, as on a property, and the handler receives the string.
  • MCP. list_object_types and list_action_types list the allowed values under choices (null when a property or parameter has none).
  • Refused at declaration (ONTOLOGY_INVALID): a member that is not a string, such as an IntEnum or Literal[1, 2]; a choice annotation combined with prop(choices=...); and a choice annotation on the primary key.

Struct properties and parameters

Annotate a property or an ActionParams field with a flat Pydantic BaseModel. For example, amount: Money declares a struct property. The same annotation on an action parameter declares a struct parameter.

class Money(BaseModel):
    value: float
    currency: str


class Order(OntologyObject):
    amount: Money

The generated PropertyDef or ActionParameterDef has type="struct" and a non-empty fields tuple of StructFieldDef values. Each inner declaration carries name, a scalar type (str, int, float, bool, date, or datetime), optional string choices, and required. A non-struct declaration has fields=None. An optional outer model makes the property or parameter optional; an optional inner annotation makes that inner field optional. An absent optional inner field in a dict is stored as explicit null, so typed hydration supplies None even if the model field has no default.

Writes accept a model instance or an equivalent dict and validate the declared inner fields. A bad object write raises INVALID_RECORD; a bad action parameter raises INVALID_PARAMS, with the message naming the inner path such as amount.currency. A typed read returns the model, while string and MCP reads return a plain object. Updating a struct replaces the whole value. Sensitivity applies to the whole property.

Structs are flat: nested models, lists or maps of models, inner aliases, inner property metadata, RootModel, and struct primary keys are refused at declaration. Use prop(property_type="json") on a flat BaseModel annotation to store it opaque; other explicit property types are refused. Querying by an inner field is unsupported. A where condition on the struct property raises OPERATOR_TYPE_MISMATCH; order_by raises INVALID_PARAMS; group_by raises INVALID_GROUP_BY; and numeric aggregate functions raise NON_NUMERIC_AGGREGATE (count is exempt). MCP's list_object_types and list_action_types include fields on every property and parameter: null for non-struct values, and a list of objects with name, type, required, and choices for a struct.

Field markers: ref, target, scope_ref

All take an OntologyObject subclass and pass extra kwargs to Field.

Marker Declares
ref(cls) This param refers to an object of cls
target(cls) …and it is the action's target — scope is enforced against it. For an unscoped type there is no scope to enforce, so roles= is the only gate. One target() param must refer to the action's target= type
scope_ref(cls) …and it names the scope the action creates within. It may not refer to an unscoped type (SCOPE_POLICY_ERROR)

These drive refers_to / scope_semantics on the generated ActionParameterDef, so scope enforcement is declared by the author rather than hardcoded in the engine.

ActionParams

Base class for typed action-params models. Fields use the markers above.

class EscalateTicketParams(ActionParams):
    ticket_id: str = target(Ticket)
    reason: str | None = None

A date or datetime parameter follows the property rule in Date and datetime values and is refused with INVALID_PARAMS; over MCP execute_action the value is a JSON string, so the string rule applies.

Events: Event, @ontology.event, emits=, ctx.emit, client.events

An event is a business fact an action records, such as "order shipped". It is not audit: audit says who did what, and an event says what happened. Declare an event as an Event subclass. Its fields use the same type rules as ActionParams: scalars, choices, and flat structs. Sensitivity works as it does on properties, and a restricted field must be optional. An event has no primary key, no transitions, and no scope_level.

from ontary import Event

@ontology.event(description="An order has shipped.")
class OrderShipped(Event):
    carrier: str

@ontology.event(*, description=None, api_name=None, accept=()) registers the class as an EventTypeDef. The class must subclass Event, and one class may be decorated once; otherwise the call raises ONTOLOGY_INVALID. accept= takes "EVENT_NEVER_EMITTED".

An action lists the events it may emit with emits=[...]. ActionTypeDef.emits holds their api names, which MCP list_action_types shows as emits. A class in emits that is not a registered event of the same Ontology raises ONTOLOGY_INVALID at declaration.

@ontology.action(ShipOrder, target=Order, roles=["ops"], emits=[OrderShipped])
def ship(ctx: ActionContext, p: ShipOrder) -> dict[str, Any]:
    order = ctx.get(Order, p.order_id)
    ...
    ctx.emit(OrderShipped(carrier=p.carrier))
    return {}

ctx.emit(event, *, about=None) records the event inside the action's transaction. The subject defaults to the action's resolved target. When the action has no target id, such as a creating action, pass about=<object>. That object must come from this context (get, all, create, or traverse) and be of the action's target type. One invocation may emit many events, including the same type twice; they are kept in emission order. The event's ts is ctx.now(). Refusals raise inside the handler, so the action rolls back and is audited as error:

Case Code
The event class is not registered on this Ontology UNKNOWN_NAME
The event is not in the action's emits UNDECLARED_EVENT
The subject cannot be resolved, is not of the target type, was not handed out by this context, or has no row yet EVENT_SUBJECT_INVALID

client.events(event_type=None, /, *, about=None, since=None, until=None) returns the events the client's consumer may see, as list[EventRecord[E]], in emission order. about is an object or a (cls, id) tuple. since is inclusive and until is exclusive; both must be timezone-aware. There is no paging. Passing an event class hydrates each payload into that class; without one, each payload is a plain Event that keeps its stored fields.

records = client.events(OrderShipped, about=(Order, "o-1"))
records[0].payload.carrier

An event is visible when its subject's latest row passes the consumer's scope and row_visibility. For a retired subject, that row is the last one before retirement. After a subject moves scope, the new scope sees its whole history and the old scope sees none. An event type no longer declared is hidden. Payload fields restricted by Sensitivity for the consumer kind are None in the payload and listed in redacted_fields.

EventRecord is frozen and generic. Its fields are event_type, about_type, about_id, ts, invocation_id, payload, and redacted_fields: frozenset[str].

Events are stored on the invocation's audit row, so they commit or roll back with the action. AuditEntry.events lists them unredacted; see AuditEntry.

Advisory findings: Finding.guide, accept, and snapshot

ontology.diagnose() returns a Finding for each advisory code (the modelling lints and the security lints listed in the CLI reference). A Finding has the fields code, severity, location, message, fix_hint, and guide.

  • Finding.guide (str | None, default None) is the URL of the section of the published English design guide that explains the finding. It is GUIDE_URL + "#" + anchor, built from ontary.diagnose.GUIDE_URL and ontary.diagnose.GUIDE_ANCHORS. Every code in ontary.diagnose.ADVISORY_CODES sets it. Findings with no guide section (ONTOLOGY_INVALID, SCOPE_POLICY_ERROR, INVALID_RECORD, RULE_VIOLATED, DIAGNOSE_RULE_FAILED) leave it None.
  • accept says "this is intentional" at the declaration that caused the finding. It takes one lint code or a sequence of codes. An accepted finding does not appear in diagnose(), in the ontary validate text, or in --json. A code that is not in the table below is refused (see the last bullet).
Declaration accept argument Codes it accepts
prop(...) accept= STORED_DERIVABLE, FREE_TEXT_STATUS
@ontology.object(...) accept= FORBIDDEN_TYPE_NAME, AUDIT_TYPE
@ontology.action(...) accept= CRUD_ACTION_NAME, MICRO_ACTION
@ontology.function(...) accept= CRUD_ACTION_NAME
@ontology.event(...) accept= EVENT_NEVER_EMITTED

The accepted names are the Literal aliases PropertyLint, ObjectLint, ActionLint, FunctionLint, and EventLint in ontary.meta. They are not exported from ontary, but they make a wrong code a mypy error at the call site. The same accept field (tuple[str, ...], default ()) exists on PropertyDef, ObjectTypeDef, ActionTypeDef, and FunctionDef, so a hand-built descriptor behaves like a decorated class. - snapshot (bool, default False) on @ontology.object(...) declares the type a point-in-time snapshot. It exempts the type from two findings: a STORED_DERIVABLE finding for any of its properties, and a FORBIDDEN_TYPE_NAME finding for a Snapshot name suffix. It does not exempt a V<digits>, year, or History name. A *Snapshot type without snapshot=True still warns. The text "declared snapshot" in description has no effect. - A refused code. A code that a declaration cannot accept raises ValidationFailed with code ONTOLOGY_INVALID when the declaration is built. The message names the declaration, the refused code, and the codes that declaration accepts. This holds for an unknown code, a code for another kind of declaration, an error code, and a security lint code (UNSCOPED_SENSITIVE, MIN_N_UNSET). Lint codes are finding codes. They are never raised, so they are not in ERROR_CODES and not in the error code table.

@ontology.object(layer="L0", scope="unscoped", snapshot=True)
class AccountSnapshot(OntologyObject):
    id: str = prop(primary_key=True)
    health_score: int  # no STORED_DERIVABLE: the type is a declared snapshot

@ontology.object(layer="L0", scope="unscoped")
class Applicant(OntologyObject):
    id: str = prop(primary_key=True)
    credit_score: int = prop(accept="STORED_DERIVABLE")  # recorded from a bureau

Scope policy

Scope is declared, never inferred. Four rule types compose into a ScopePolicy; you normally never build one by hand — @ontology.object(scope=[...]) does it.

Rule Fields Resolves the level from
SelfScope level The object's own id
DirectProperty level, property_name A property on the row
ViaLink link_api_name, direction ("from"/"to"), parent_type Following a link to a parent, recursively
CustomResolver level, fn(store, obj_type, obj_id) -> str \| None Arbitrary caller logic

ScopePolicy

Field Type Notes
levels list[str]
unscoped_types set[str] A type here must NOT also appear in rules -- validate() and every read refuse the overlap with SCOPE_POLICY_ERROR.
rules dict[str, list[ScopeRule]]
contributor_rules dict[str, list[ScopeRule]]
row_visibility dict[str, RowVisibilityFn]
min_n int

Resolution helpers

resolve_owning_scope(policy, store, obj_type, obj_id) -> dict[str, str | None]
resolve_contributor(policy, store, obj_type, obj_id) -> str | None
covers_scope(policy, consumer, resolved) -> bool

resolve_owning_scope returns one entry per declared level. covers_scope denies when the consumer's level is unresolved — scope enforcement never fails open.

ValidationFailed (SCOPE_POLICY_ERROR) is raised when a rule references an undeclared object type, link type, or level.


Runtime and clients

runtime = ontology.bind(store, capabilities={...})                  # once
client  = runtime.for_consumer(consumer)                            # cheap, per request

Ontology.bind(store, *, clock=None, id_factory=None, capabilities=None)

clock is a callable returning a timezone-aware datetime; it defaults to datetime.now(timezone.utc). id_factory is a callable returning str; it defaults to UUID-shaped IDs. Both seams are stored on the shared runtime and are inherited by every for_consumer() view.

Binding with clock=c installs c on the store. From then on every write to that store uses it: action writes, ontary.ingest.bulk_upsert / bulk_link, and direct store calls. valid_from / valid_to come from it.

  • One instant per invocation. An action reads the clock exactly once. That instant is what ctx.now() returns, the valid_from / valid_to of every row and link the action writes (including links retire closes), and the ts of every audit entry of the invocation (ok, denied, error).
  • CLOCK_CONFLICT. Binding the same store with a different clock object raises PreconditionFailed. The same clock object, or no clock=, is fine; a runtime bound without clock= uses the store's installed clock.
  • CLOCK_REGRESSION. A write whose instant is earlier than the valid_from of the row or link it closes raises PreconditionFailed. Inside an action the whole action rolls back and an error audit entry records the code. An equal instant is allowed.
  • CLOCK_NOT_TIMEZONE_AWARE. A clock that returns a naive datetime raises ValidationFailed; nothing is stored.

Bind first, then seed: data seeded under another clock can make the first update or retire under a clock set in the past raise CLOCK_REGRESSION.

OntologyRuntime(ontology, store, handlers=None, *, clock=None, id_factory=None, capabilities=None)

Shared, consumer-free machinery for one (ontology, store) pair — query layer, action executor, bound handlers — wired exactly once.

  • .for_consumer(consumer, *, capabilities=None) -> OntologyClient — a cheap view. Serving many consumers from one process never re-wires anything.

OntologyClient(ontology, store, consumer, *, capabilities=None)

Bound to exactly one (ontology, store, consumer). Constructing it directly works and builds a single-use runtime internally.

Method Returns
.get(obj_type, obj_id) T \| StoredObject \| None
.list(obj_type, where=None, *, limit=DEFAULT_READ_LIMIT, after=None, order_by=None) list[T] \| list[StoredObject] \| TypedPage[T] \| Page
.traverse(obj_type, link, from_id, *, reverse=False) or .traverse(link_cls, from_obj_or_id, *, reverse=False) list[T] \| list[StoredObject]
.aggregate(obj_type, value_field=None, where=None, *, func="mean") float \| int
.aggregate_by(obj_type, value_field, group_by, where=None, *, func="mean") dict[str, float \| int]
.count(obj_type, where=None) int
.exists(obj_type, where=None) bool
.count_contributors(obj_type, where=None) int
.execute(params) or .execute(action, params) dict[str, Any]
.call_function(params: FunctionParams) or .call_function(api_name: str, params: dict[str, Any] \| None = None) Any
.ingest(obj_type, records, source, *, on_error="raise") IngestReport (raises IngestError when failures exist)
.ingest_links(link_api_name, pairs, source, *, on_error="raise") IngestReport (raises IngestError when failures exist)

Typed vs. dynamic. Pass the author's class to get typed instances back (client.get(Ticket, id) -> Ticket | None, mypy-inferred, zero casts). Pass a string to get StoredObjects — the dynamic form, which is also MCP's wire shape and what generic tooling uses. .traverse takes a LinkHandle first for the typed form: client.traverse(link_cls, from_obj_or_id).

Every call's where / order_by / group_by / value_field keys are checked against the type's declared properties, on the string form as well as the typed one: an unknown key raises UNKNOWN_FIELD rather than silently matching nothing or returning a number. This includes a key that some stored row happens to carry — an undeclared payload key is allowed on write, but it is not addressable by name in a read. A hidden but declared key is refused with VisibilityError and VISIBILITY_DENIED when the operation would disclose it; the narrow scope-routing and Function-aggregate exceptions are described under Filters and Aggregates below.

For object reads, omitting limit applies DEFAULT_READ_LIMIT=1000 and returns a Page (or TypedPage for a typed call). Passing limit=None explicitly selects the unbounded list form; a positive integer selects a page. order_by accepts a declared payload field (ascending by default) or a (field, "asc"|"desc") pair. Unknown fields raise UNKNOWN_FIELD; hidden fields are refused with VISIBILITY_DENIED, even when they are DirectProperty scope-routing keys. Unlike the equality-shaped where exemption below, order_by always uses the un-exempted hidden-field gate.

Two asymmetries between the surfaces, both easy to trip over:

Hydration. A typed read parses a datetime-typed property into a real datetime (via Pydantic); the string surface returns the ISO-8601 string exactly as stored. The store never rewrites what it persists — only the typed client's hydration step parses on the way out. What a write accepts is in Date and datetime values.

Redaction shape. A redacted field comes back as None on the typed surface, with its name in redacted_fields. On the string and MCP surfaces the key is absent from payload entirely — there is no redacted_fields companion there. Read defensively over MCP (payload.get("email"), not payload["email"]), and do not infer "not stored" from a missing key: it may simply be hidden from you.


Reading

StoredObject

Field Type
payload dict[str, Any]
lineage Lineage

A frozen payload/lineage split — never a bare dict with smuggled _object_type-style keys.

Lineage

object_type, object_id, valid_from, valid_to, source_system, source_id, extracted_at.

Filters, ordering, and bounded reads

where accepts a bare scalar for equality or a mapping whose keys are drawn from gt, gte, lt, lte, in, ne, and contains. A mapping with several keys is their conjunction on that one field, so {"gte": a, "lt": b} is a half-open range and {"contains": "x", "ne": "x"} is a substring match with one value excluded; an empty mapping is refused. Comparisons apply to declared int, float, date, or datetime properties. date and datetime operands are the same ISO-8601 strings the property stores, or date / offset-aware datetime values, which are converted to that spelling first; a datetime must carry a time component, so a date-only string is OPERATOR_TYPE_MISMATCH rather than a naive midnight that never matches an offset-aware column. The four comparison operators treat a datetime as an instant, so an operand in another UTC offset matches by moment, not by spelling, and a stored value whose offset-awareness differs from the operand's (naive against aware) does not match; eq, ne, and in on a datetime still match the stored spelling exactly. in takes a list of declared values, ne applies to every declared type, and contains is a substring test for str. Unknown operators raise UNKNOWN_OPERATOR; an operator or operand that does not match the declared property type raises OPERATOR_TYPE_MISMATCH — including the bare scalar, which is the only equality spelling. None is the exception: it is the null test, selecting rows where the property is absent. An int operand on a declared float is widening, not a mismatch. Mapping-valued where is the same grammar on typed, string, aggregate, Function, and MCP read surfaces.

A where condition on a struct property raises OPERATOR_TYPE_MISMATCH with a message such as where is not supported on struct property 'amount'. Inner paths such as amount.currency cannot be queried; they are not top-level properties.

Because a mapping value is operator syntax, dict equality on a declared json property is not spelled where={"data": {"kind": "a"}} — that mapping is parsed as operators, not a value to match. Wrap it as an in list of one instead: where={"data": {"in": [{"kind": "a"}]}} is the equality escape.

For a hidden property declared as a DirectProperty scope-routing key, the scope-key exemption applies only to a bare eq value and a lone in over an explicit list. gt, gte, lt, lte, ne, and contains, plus in over a non-list, any malformed shape, and any multi-key mapping (even one that contains an in), are refused with VISIBILITY_DENIED because they let the caller learn a value. The operand must also actually supply a value — a str, int, float, bool, or date — so None, a list containing None, and an empty in list are refused too: a bare null needs no prior knowledge and would be a per-row null probe. Null filtering on a readable field is unchanged. Each where mapping, and each condition inside it, is read exactly once at the public boundary; every gate and the row matcher consume that one snapshot, so a mapping that answered differently on a second read cannot be classified as one predicate and executed as another.

where must be a mapping or omitted. Anything else — a string, a list, a number — raises INVALID_PARAMS on every read surface, including a falsy one: "", 0, [], and False are refused rather than treated as "no filter". An empty mapping {} still means no filter, as does None.

order_by accepts a declared payload field, ascending by default, or a (field, "asc"|"desc") pair. Unknown fields raise UNKNOWN_FIELD; hidden fields are refused with VISIBILITY_DENIED, including hidden scope-routing keys, because the returned rank discloses the value. group_by has no scope-key exemption either: its returned dictionary key discloses the group value. Lineage fields are not payload fields.

On the consumer surfaces (OntologyClient and GuardedQuery), omitting limit applies DEFAULT_READ_LIMIT=1000 and returns a Page (or TypedPage for a typed call). Pass limit=None explicitly for the unbounded list form. Inside a declared Function, BoundQuery.list is unbounded when limit is omitted and returns a bare list; pass a positive limit for a Page. Pagination composes with order_by and after on the bounded surfaces.

Pagination — Page / TypedPage[T]

Both carry items and an opaque next_cursor: str | None. On the consumer surfaces, omitting limit returns a page using the default bound above, while limit=None is the explicit unbounded-list opt-in. Inside a declared Function, BoundQuery.list returns a bare list when limit is omitted or None; passing a positive limit returns a page.

The contract:

  • Exact-size pages. A page holds exactly limit items whenever that many visible rows remain — filtering downstream of the store read can never shorten a page. A short page always means "no more rows", never "some were hidden from you".
  • The cursor is opaque. A random per-row token. Not a row id, not a count, not an order. Never parse it; store it and pass it back to after=.
  • next_cursor is None means exhausted-before-full. A page that fills exactly at the last row still carries a cursor; the follow-up call returns one final empty page. So while next_cursor is not None always terminates correctly rather than stopping a page early.
  • During an unordered walk, a mid-walk update may repeat a row but never skip one. The store is close-old-insert-new. During an ordered walk, a row whose sort value moves ahead of the cursor is silently dropped, while a row whose sort value moves behind it is duplicated. Only a quiescent ordered walk is gap-free and repeat-free.
  • after without limit → AFTER_WITHOUT_LIMIT. limit < 1 → INVALID_LIMIT. A malformed or unknown cursor → INVALID_CURSOR. An ordered walk cannot resume after any write to its own cursor row — including a retirement or an update that supersedes it — and raises STALE_CURSOR; restart from the first page.

Scale caveat for ordered pages. Each ordered page currently materializes and sorts the whole object type, regardless of limit or where selectivity. In the measured 20,000-row case, limit=10 read all 20,000 rows with order_by versus 500 without it: O(N log N) per ordered page, and O(N² log N) for a full ordered walk.

traverse

client.traverse(link_cls, from_obj_or_id) is the typed form; client.traverse("Comment", "commentOnTicket", comment_id) names the source type, link API name, and source id in that order. BoundQuery accepts the same handle-first typed form inside Functions.

Returned targets are subject to the same visibility checks as any other read. Traversal through an identity-revealing link raises VisibilityError with VISIBILITY_DENIED for a human consumer before the target is returned; AI consumers still receive normal scope and sensitivity enforcement on the target rows. reverse=True traverses from the link's target side, and the identity-revealing denial is symmetric in both directions.

Visible-row counting

count(obj_type, where=None) returns the number of matching rows after the consumer's scope and row-visibility checks; exists(...) reports whether that same post-visibility selection is non-empty. Both accept the same where operator grammar as list. A consumer scoped away from every matching row receives 0 from count and False from exists, not a privacy refusal.

These two operations are deliberately not min-N-gated. They reveal only the size of a row set after post-visibility filtering, and the consumer can already enumerate the same rows with list(..., limit=None), so a min-N refusal would add no disclosure protection. count_contributors remains the sole privacy-counting primitive: unlike visible-row counting, it resolves the distinct contributor population behind an aggregate and therefore keeps the aggregate's min-N release discipline.

Aggregates

aggregate(...) and aggregate_by(..., group_by=...) accept func="mean"|"count"|"sum"|"min"|"max"; the default remains "mean". Ungrouped aggregation returns a plain numeric value: count is an int and the other functions return float. Grouped aggregation returns the corresponding value per group in a dict.

func="count" accepts any declared field type — it counts the rows carrying that field and never coerces the values to float. Its value_field may also be omitted (or passed as None) to count every visible row in the selection, min-N gated over those rows' contributors (per group, when grouped); omitting value_field for any other func raises INVALID_PARAMS, naming the func and saying value_field is required.

Both enforce hidden-field checks on where/group_by, min-N over distinct contributors, UNKNOWN_FIELD for a value_field the type does not declare, and NON_NUMERIC_AGGREGATE for a declared but non-numeric one under mean/sum/min/max (count is exempt; both checks run before any row is read). A hidden value_field may be aggregated only from an author-declared Function, over a type declaring contributor_rules, and only with func="mean" or func="count". From a consumer surface — client, typed, or MCP — that operation raises VisibilityError with VISIBILITY_DENIED; sum, min, and max remain refused. Every function uses the same min-N release discipline. An empty visible selection raises MIN_N_VIOLATION; aggregate_by also refuses the grouped-empty {} case instead of returning an empty dictionary.

group_by is validated like every other field name on this surface, on all forms rather than only the typed one: a name the type does not declare raises UNKNOWN_FIELD, exactly as where keys and order_by do, instead of matching nothing and returning the ungrouped value under the key "None". A falsy group_by is never a silent collapse to the ungrouped path either — the code still differs there by surface: INVALID_GROUP_BY on the string form and BoundQuery, UNKNOWN_FIELD on the typed form (the class-property check runs first).

A json-declared group_by raises INVALID_GROUP_BY: its values may be a dict or list and so need not be hashable. The declared type is what is checked, not the stored values, so a json property that happens to hold only scalars refuses too rather than working until the first dict arrives. Struct properties also raise INVALID_GROUP_BY when used as group_by; the declared struct value is not a supported group key.

Two distinct group values that release as the same dictionary key raise GROUP_KEY_COLLISION — most often an optional property, which keys None on the rows that lack it and collides with a row carrying the literal string "None". One released cell can only describe one population. The check runs per group as each is released, after that group's min-N test, so a selection that min-N would withhold still raises MIN_N_VIOLATION.

aggregate, aggregate_by, and count_contributors refuse an unregistered object type with UNKNOWN_OBJECT_TYPE, matching what the same calls have always done when a where= is present.

GuardedQuery(store, registry, policy)

The engine-level read path, taking an explicit consumer per call: .get_object, .get_objects, .traverse, .aggregate, .aggregate_by, .count, .exists, and .count_contributors. Most callers use OntologyClient instead.


Actions

An action is a typed params class plus a handler decorated with @ontology.action(params_cls, target=..., roles=[...], capabilities=()).

Every execute runs the same pipeline:

registered? → role permitted? → scope covers the declared target/scope param? → preconditions → transactional side effects → append-only audit

Every attempt is audited — ok, denied, and error alike.

ActionContext

What a handler receives instead of a raw store handle. Writes are auto-stamped with the action's own Source.

Typed members. These take the ontology's own classes and LinkHandles, so mypy checks the class, the returned type, each link endpoint's type, and every attribute a handler assigns before save. create's keyword names are checked at runtime only.

Member Purpose
.get(cls, obj_id) -> T \| None Read one current object
.all(cls) -> list[T] Every current object of cls
.create(cls, **values) -> T Create and return the object. A missing primary key is minted from the runtime's id_factory; a primary key that is already live is refused with OBJECT_ALREADY_EXISTS; a name that is not a declared property is refused with INVALID_RECORD
.save(obj) Write the declared properties changed since this context handed obj out, and nothing when none changed. Only an object from get, all, create or traverse can be saved (OBJECT_NOT_LOADED otherwise); a changed primary key is refused with PRIMARY_KEY_IMMUTABLE
.link(handle, from_, to) Link. Each end is an object or its id, typed by the handle. Both ends must be live objects of the link's declared endpoint types, or the call is refused with LINK_ENDPOINT_NOT_FOUND. A link identical to a live one is a no-op
.unlink(handle, from_, to) Close one live link, ends as for link
.traverse(handle, anchor) -> list[To] The To objects linked from anchor; reverse=True returns the From objects linked to it
.retire(obj) / .retire(cls, obj_id) Retire the object and close every live link that touches it

A date or datetime value passed to create or assigned before save follows Date and datetime values; a refused value raises INVALID_RECORD.

A typed handler reads an object, changes it, and saves it:

order = ctx.get(Order, params.order_id)
order.status = "shipped"          # mypy checks the field name and type
ctx.save(order)                   # writes only `status`

Removed in 0.18.0. Use the typed replacements below. The string form of retire or unlink raises ValidationFailed with code INVALID_PARAMS. unlink is positional-only. A keyword call raises TypeError.

Removed call Typed replacement
.insert(obj_type, payload) .create(cls, **values)
.update(obj_type, obj_id, changes) .get(cls, obj_id) + assignment + .save(obj)
.create_link(link_api_name, from_id, to_id) .link(handle, from_, to)
.retire(obj_type, obj_id) .retire(obj) / .retire(cls, obj_id)
.unlink(link_api_name, from_id, to_id) .unlink(handle, from_, to)
.read_current(obj_type, obj_id) .get(cls, obj_id)
.read_all(obj_type) .all(cls)
.links_from(link_api_name, from_id) .traverse(handle, anchor)
.links_to(link_api_name, to_id) .traverse(handle, anchor, reverse=True)

Other members.

Member Purpose
.capability(handle) -> P Fetch a declared capability
.consumer The calling Consumer
.emit(event, *, about=None) Record a declared event on this invocation. See Events under Authoring an ontology
.now() -> datetime The invocation's single instant (see Ontology.bind). Use it instead of datetime.now() or a clock capability

get and all are trusted handler reads. They return raw, unredacted, and unscoped objects. They are intentionally not guarded consumer queries. Filtering an enumeration by scope or sensitivity could hide an existing row from an id allocator and cause id reuse.

retire and unlink are the only handler-facing SDK removal operations. They may be called only inside the engine-owned action transaction. retire closes the object's current row and cascade-closes every live link that references the object on the side its link type declares for that object type, in that transaction; the object and every link closure are captured in AuditEntry.writes. unlink closes one matching live link.

Raise ActionError for a failed precondition. code is required (0.6.0): pass PRECONDITION_FAILED, or your own stable code.

Authority

Writes are refused unless declared. An action creating an object of a type that is not whole-type owned=True raises SOURCE_CREATE_REFUSED; updating a source-backed property, or creating a source-backed link, that is not declared ontology-owned raises UNDECLARED_SOURCE_WRITE. Source data and ontology-owned state stay separable.

Removal uses the same authority boundary: retire requires the whole object type to declare owned=True, and unlink requires the link type to declare owned=True. Otherwise the action raises UNDECLARED_SOURCE_REMOVAL. If an object retirement cascade reaches a source-backed link, the whole action transaction rolls back, including earlier link closures.

execute() called while the caller already holds a store transaction is refused (CALLER_TRANSACTION_REFUSED, not audited) — otherwise an applied-and-audited action could be rolled back underneath the audit log.

AuditEntry

ts, actor, role, action, target_type, target_id, params, outcome, invocation_id, error_code, plus integrity records: writes: list[WriteRecord], capability_accesses: list[CapabilityAccessRecord], events: list[EmittedEvent].

kind: Literal["action", "function"] — what produced the entry. Actions and functions share one log; kind is how a reader tells them apart, since nothing stops an ontology declaring an action and a function with the same api_name. On a function entry, action holds the function's api_name, target_type is "" (a function has no target object type), and writes is always empty.

invocation_id: str | None — one id per execute() (or audited call_function()) call, stamped on every entry that call writes. Correlate entries by this value rather than by matching fields and append order — two calls to the same action with the same params are otherwise indistinguishable. None means the entry predates the field (a store file written by an older engine); it is never invented for such rows.

events: list[EmittedEvent] — the events an ok action entry emitted, in emission order and unredacted, since audit is the administrative view. Every denied and error entry lists none.

unscoped_params: list[str] — the target(...) parameters that skipped the action scope gate because they refer to a scope="unscoped" type. For them the action's roles= was the only gate. The list is empty when every scope-bearing parameter was scope-checked, and on entries written before the gate ran (for example a role denial).

target_id: str | None — on an action entry, the id the caller passed in the action's declared target parameter. Every outcome records it, including denied and error, even when that id does not exist. None when the action declares no target parameter, and on every function entry.

error_code: str | None — why a denied or error entry failed: the raised exception's catalogued code (for example PERMISSION_DENIED, SCOPE_DENIED, INVALID_PARAMS, or a handler's own ActionError code). An exception that carries no code, such as a bare KeyError from a handler, is recorded as INTERNAL_ERROR, the code the MCP server reports for it. None on an ok entry.

  • WriteRecord — op (create/update/link), object_type, link_type, object_id, from_id, to_id
  • CapabilityAccessRecord — api_name, count
  • EmittedEvent — event_type, about_type, about_id, payload (storage form)

Functions

A function handler takes a BoundQuery. A typed function also declares a FunctionParams subclass with @ontology.function(params_cls, ...). FunctionParams rejects unknown fields; its annotations define the validated inputs and MCP parameter schema. No store handle ever reaches the handler — only already-guarded reads. Functions return derived values and never write.

class TicketStatsParams(FunctionParams):
    queue_id: str

@ontology.function(TicketStatsParams, api_name="ticketStats")
def ticket_stats(query: BoundQuery, params: TicketStatsParams) -> float:
    mean = query.aggregate("Ticket", "age_hours", where={"queue_id": params.queue_id})
    assert isinstance(mean, float)
    return mean

client.call_function(TicketStatsParams(queue_id="q1"))

There are two declaration forms. A typed function declares a FunctionParams subclass; callers may pass an instance as above or pass its values in a dict with the function name, such as client.call_function("ticketStats", {"queue_id": "q1"}). The dict is validated and the handler receives a TicketStatsParams instance. A no-input function omits the params class and takes only query:

@ontology.function(api_name="health")
def health(query: BoundQuery) -> bool:
    return query.exists("Ticket")

client.call_function("health")
client.call_function("health", {})

Without a params class, the handler must take exactly one parameter, query, with no default. Any other handler is refused at declaration with ValidationFailed and code="ONTOLOGY_INVALID". Dict-form handlers such as (query, params: dict) were removed in 0.20.0; declare a FunctionParams subclass instead.

Unknown fields, missing required fields, invalid types, and values outside declared choices raise ValidationFailed with code="INVALID_PARAMS" before the handler runs. A no-input function also rejects non-empty params with that code.

FunctionDef.parameters and MCP list_functions expose typed inputs as parameters with name, type, choices, fields, required, and refers_to, matching the action parameter shape without scope_semantics. The value is [] for a no-input function. client.call_function refuses an argument that is neither a function name nor a FunctionParams instance with ValidationFailed and code="INVALID_PARAMS".

BoundQuery

A GuardedQuery with the consumer fixed: .get, .list, .count, .exists, .traverse, .aggregate, .aggregate_by, .count_contributors, .capability(handle). Typed overloads work the same as on OntologyClient (query.get(Ticket, id) -> Ticket | None).

For a function with no inputs, declare a one-argument handler (query) and call it with no params or {}.

PreconditionFailed (FUNCTION_ERROR) covers an undeclared api_name, a duplicate registration, or no bound handler.

The function audit boundary

For an audited call, OntologyClient.call_function appends one kind="function" audit entry — carrying the invocation id, the params, the outcome, and the handler's capability_accesses. writes is empty by construction.

Whether a function is audited is conditional, via FunctionDef.audited:

Declaration Audited?
declares capabilities ✅ yes (default)
declares none ❌ no (default)
@ontology.function(audit=True) ✅ yes, always
@ontology.function(audit=False) ❌ no — except a release, below

Functions run far more often than actions, and one that declares no capability cannot reach outside the process — everything it reads is already bounded by the guarded query layer. Auditing every call would be write amplification for little gained, so the default records the case an auditor actually asks about.

The error path is audited too: a handler that reached outside and then raised has already had its effect on the world.

A hidden-field release is audited whatever the declaration says. The guarded query layer's bound includes the AC10 contributor exemption, so a capability-less function can hand a consumer a mean or count over a field they cannot read themselves. When a call actually does that, call_function appends its entry — audit=False included, because an ontology should not be able to opt out of recording that it released an individual-bearing number.

The trace follows the release, not the declaration:

The call Audited?
released a hidden field through the exemption ✅ yes, whatever was declared
aggregated a field the consumer could read anyway per the table above
opened the exemption but min-N refused per the table above — nothing was released
declaring contributor_rules anywhere in the ontology per the table above — declaring is not releasing

Two reasons to audit one call still append one entry.

A bare FunctionRegistry.call has no boundary — the guarantee belongs to the client surface, the same way the write gate belongs to execute() rather than to the store.


Capabilities

Anything a handler wants from the outside world must be declared, then provided at bind time. Undeclared use is refused, and every use is audited. A capability is a thing a handler reads or calls.

Mailer = ontology.capability(MailerProto, name="mailer")

@ontology.action(P, target=T, roles=["Agent"], capabilities=[Mailer])
def handler(ctx, params):
    ctx.capability(Mailer).send(...)

For the current time use ctx.now(), not a capability.

Requesting an undeclared capability raises UNDECLARED_CAPABILITY; a declared one with no provider bound raises CAPABILITY_NOT_PROVIDED.

Providers are bound at ontology.bind(store, capabilities={...}) or per client via for_consumer(...).


Security

Consumer

Field Type
actor_id str
role str
scope_level str — one of the ontology's declared levels
scope_id str
kind Literal["human", "ai"]

Four mechanisms, all enforced in the engine:

  1. Scope visibility — resolved through the declared ScopePolicy; an unresolved level denies.
  2. Sensitivity redaction — Sensitivity(ai_usable, human_visible) per property. A hidden field reads back None and is named in redacted_fields.
  3. min-N — aggregates over fewer than min_n distinct contributors raise VisibilityError with code MIN_N_VIOLATION. Contributors come from the declared contributor rules, so repeated rows from one person do not clear the bar. The count is taken over the rows that carry value_field, not every row in the selection — a group of min_n people in which only one answered does not release that answer. Rows whose contributor does not resolve count as one unknown identity between them, never one apiece, so closing a link or retiring a contributor cannot turn one person's rows into a releasable population.
  4. Identity-revealing links — refused for human consumers before any target is resolved.

covers_scope(policy, consumer, resolved) -> bool is the single coverage rule, shared by the read path and the write path.


Stores

The Store protocol

OntologyClient / GuardedQuery / ActionExecutor are typed against this protocol, not a concrete backend.

insert(obj_type, payload, source) -> str
update(obj_type, obj_id, payload_changes, source) -> None
read_current(obj_type, obj_id) -> StoredObject | None
read_last(obj_type, obj_id) -> StoredObject | None
read_all(obj_type) -> list[StoredObject]
read_page(obj_type, after_key=None, batch=500) -> list[PagedRow]
retire_object(object_type, obj_id) -> StoredObject
create_link(link_type, from_id, to_id) -> None
close_link(link_type, from_id, to_id) -> bool
links_from(link_type, from_id) -> list[str]
links_to(link_type, to_id) -> list[str]
links_from_asof(link_type, from_id, asof) -> list[str]
links_to_asof(link_type, to_id, asof) -> list[str]
append_audit(entry) -> None
audit_entries() -> list[AuditEntry]
transaction() -> ContextManager
capture_action_writes() -> ContextManager[list[WriteRecord]]

insert refuses a primary key that already has a live row of the same object type with ConflictError and code OBJECT_ALREADY_EXISTS; the store is left unchanged. An object has at most one live row, and the SQL backends back this with a partial unique index. A retired object's id may be inserted again, which starts a new live row.

insert and update take a date or datetime value as an ISO-8601 string or a date / offset-aware datetime object; see Date and datetime values.

create_link needs a live object at both ends. Each id is looked up under the link type's declared from_type / to_type; an id that is missing, retired, or live only as some other type is refused with ValidationFailed and code LINK_ENDPOINT_NOT_FOUND, and no link row is written. The endpoints are checked before cardinality. An object inserted earlier in the same action transaction counts as live.

create_link is idempotent: creating a link identical to a live one, for any cardinality, is a no-op. Nothing is written, no WriteRecord is captured, and the call returns normally; it used to duplicate a MANY_TO_MANY link and to refuse a MANY_TO_ONE link with CARDINALITY_VIOLATION against itself. The identical-link check runs before cardinality. Only a live link counts: after close_link, the same pair may be linked again, and the closed row is kept as history. The SQL backends back this with a partial unique index on live links.

A primary key is immutable. update refuses a change that gives the primary key a different value with ValidationFailed and code PRIMARY_KEY_IMMUTABLE, and the store is left unchanged. This keeps the store id and the payload primary key equal. Repeating the current value is not a change and is accepted. To give an entity a new key, retire the object and insert a new one.

read_current returns the live row only; read_last returns the newest row whether or not it is still live. links_from/links_to return live links only; the _asof pair also returns links closed at or after the instant you name.

The three history-aware reads exist for ONE caller: the action executor's target gate, which has to tell "outside your scope" apart from "already retired". A retired object still owns the scope it was in, and ActionContext.retire closes its links, so the gate resolves that scope from the object's last row plus the links it held when that row closed. Consumer reads deliberately do not: resolving a retired row's scope would put its children back in a reader's visible set, carrying a population past min_n and releasing an aggregate over the retired subject's own rows. A backend that filtered retired rows out of read_last, or live-only links out of the _asof pair, would deny a retired object's own owner instead.

A retired target resolves the scope it owned as of the instant its own row closed. The links on the chain are read at that instant on both bounds, so an edge the object had already left cannot answer for it.

Where a ViaLink hop finds more than one parent at that instant, the first parent whose chain resolves wins, over an order the store declares rather than each backend's own row order: earliest link valid_from first, then lowest parent id, compared byte-wise. It is not creation order — links declares no monotonic key, so two links made in one clock tick are separated by id, not by which was written first. Every backend answers in that order because it decides which scope owns the object, and therefore which operator this gate admits.

An ancestor object is admitted when it had not already been retired by then — but it is read at its newest row, so its payload, and any DirectProperty key taken off that payload, is the one it carries now: an ancestor updated after the instant answers with the scope it is in today, not the one it was in then. Making the object side point-in-time too needs an as-of read of an object's history, which Store does not have. A live target has no closing instant and resolves in the present tense, exactly as a consumer read does, so this gate loosens nothing for an object that still exists.

Point-in-time rather than "retired rows allowed", because the two obvious readings fail in opposite directions. Resolving ancestors from the newest row lets one retired long ago outrank a live one and authorize an operator who no longer owns the row; resolving them live-only refuses an ancestor that was alive when the target closed and is the only route to a scope that is alive now, denying the owner permanently. At the target's valid_to the earlier-retired ancestor was already closed and the surviving one was not, so one instant settles both.

Where the chain resolves and the consumer covers it, the gate reaches the handler's own refusal (OBJECT_ALREADY_RETIRED). SCOPE_DENIED does not mean one thing: it is raised at two places. One is a defense-in-depth refusal of a scope-bearing parameter that is not a str — parameter validation rejects that first, so it is a floor under type confusion rather than a path in normal use. The other fires wherever coverage cannot be shown, and that is two situations rather than one: the chain resolved and this consumer is outside it, which is the ordinary denial this gate does not change; or the chain did not resolve at that instant, and deny-by-default denies. Only the second belongs to this frame. Four rule kinds are declared, and each meets retirement at its OWN hop:

  • SelfScope answers with the object's own id, which retirement does not take away.
  • DirectProperty reads the scope key off the payload, which retirement leaves alone: this gate reads the newest row rather than the live one.
  • ViaLink climbs the links table. It resolves to nothing when none of the parents it reaches resolves in turn — among them a parent already retired BEFORE the target closed: its edge may still be readable at that instant, but its own row is not admitted, so it cannot answer at the one instant this gate asks about. One retired after the target — including in the same cascade tick — still answers for it.
  • CustomResolver is author code handed the raw Store, and the engine does not reach inside it, so what a retired object resolves to is the resolver's own business rather than this frame's. The natural body reads read_current, which is None for a retired object, so a resolver written that way denies. A type that needs the precondition refusal after retirement declares a second rule — a DirectProperty on a scope-key column, which survives retirement.

Each bullet is about one hop, never about one target, and the four are not the whole chain. ScopePolicy.rules maps each type to an ORDERED list, so a target declares as many of these hops as that list holds and is answered by the first that resolves; and a level no rule of its own can answer climbs to the canonical instance of a narrower scope, where a type declares one — which is none of the four. What the engine's own hops share is the frame: any object the engine has to read that had already been retired before the target closed is refused there, whichever of those hops reached it — so the hop that answers a target's level can fail on an object the target's other hops never touch. A CustomResolver is outside that frame only for the reads its own callable makes: the engine does not thread the instant into author code, so an ancestor the callable reaches for itself is read however it reads it, retired or not. Its ANSWER re-enters the engine, and every object the engine reads from there is refused on the frame's own terms — the canonical instance a narrower answer names, and any object whose rules the engine goes on to ask, whose row is checked before its own resolver runs.

Every denial in this list fails closed — the object's own owner is denied, nothing is disclosed.

Three implementations ship, all proven against one shared conformance suite:

  • ObjectStore — SQLite. History (close-old / insert-new), links, audit log.
  • InMemoryStore — pure Python. No file, no SQL; for tests and dogfooding.
  • PostgresStore — PostgreSQL-backed; available with the postgres extra.

The outermost transaction() serializes a read followed by a write against concurrent writers on the same store. It is reentrant, and nested calls share the outer transaction.

ObjectStore(registry, path, *, busy_timeout=5.0) configures how many seconds SQLite waits for a database lock. ActionExecutor holds the outer transaction—and therefore SQLite's write lock—for the whole handler body, including external ctx.capability() calls. For a competing writer, the time it can wait for that handler is bounded by its store's busy timeout; expiry raises coded STORE_BUSY (kind="conflict"). Increase busy_timeout when legitimate handlers can run longer than the default, or keep capability calls short.

Source — source_system, source_id, extracted_at.

retire_object closes an object's current row without inserting a replacement; it does not cascade links. close_link closes the one live link matching all three identifiers. All three shipped backends implement these verbs. The action-context cascade is layered above the store's object-retirement primitive.

Date and datetime values

Every write path checks a date or datetime property value the same way: Store.insert / update, ActionContext.create / save, bulk_upsert / client.ingest, and action parameters (including MCP execute_action).

Value written date property datetime property
date object Stored as YYYY-MM-DD Refused
Offset-aware datetime object Refused Stored as its own isoformat(), offset preserved
Naive datetime object (no tzinfo) Refused Refused
"YYYY-MM-DD" string Stored verbatim Refused: a time component is required
Other ISO-8601 date spelling, such as "20261005" Refused —
ISO-8601 string with a time, with or without an offset (Z included) — Stored verbatim
Any other string Refused Refused
  • Nothing is normalised. The store keeps the exact spelling: an offset is not converted to UTC, and Z stays Z. eq and in filters match that spelling; the comparison operators compare instants (see Filters, ordering, and bounded reads).
  • Naive strings are accepted, naive objects are not. A naive ISO string is stored as written. A naive datetime object is refused, because adding tzinfo= is the only way to say which instant it means. Avoid mixing naive and offset-aware values in one property: the two kinds have no defined order.
  • Refusal codes. A refused value raises ValidationFailed with INVALID_RECORD on Store.insert / update, ActionContext.create / save, and per record in an ingest report. An action parameter is refused with INVALID_PARAMS before the handler runs. A where operand is refused with OPERATOR_TYPE_MISMATCH.
from datetime import datetime, timedelta, timezone

from ontary import InMemoryStore, Ontology, OntologyObject, Source, prop
from ontary.errors import ValidationFailed

ontology = Ontology("shifts", scope_levels=["org"], min_n=1)


@ontology.object(layer="L0", scope="unscoped")
class Shift(OntologyObject):
    id: str = prop(primary_key=True)
    starts_at: datetime


ontology.validate()
store = InMemoryStore(ontology.registry)
source = Source(source_system="roster")
jst = timezone(timedelta(hours=9))

store.insert("Shift", {"id": "s1", "starts_at": datetime(2026, 10, 5, 9, tzinfo=jst)}, source)
store.insert("Shift", {"id": "s2", "starts_at": "2026-10-05T00:00:00Z"}, source)
assert store.read_current("Shift", "s1").payload["starts_at"] == "2026-10-05T09:00:00+09:00"
assert store.read_current("Shift", "s2").payload["starts_at"] == "2026-10-05T00:00:00Z"

try:
    store.insert("Shift", {"id": "s3", "starts_at": datetime(2026, 10, 5, 9)}, source)
except ValidationFailed as exc:
    assert exc.code == "INVALID_RECORD"  # a naive datetime object
else:
    raise AssertionError("a naive datetime object must be refused")

Schema versioning

Every SQLite file is stamped with the engine's SCHEMA_VERSION via PRAGMA user_version when it is created; Postgres records the same number in schema_meta. Neither backend carries a migration ladder, so any other stamp — higher, lower, or an unstamped store that already has an objects table — is refused at construction with STORE_VERSION_UNSUPPORTED, naming both versions. Moving a store across schema versions is an explicit operator step: open it with the matching ontary version, or migrate the data into a fresh store. storage.md gives the drop-and-recreate procedure.


Bulk ingest

bulk_upsert(store, registry, obj_type, records, source) -> IngestReport
bulk_link(store, registry, link_type, pairs, source) -> IngestReport

client.ingest(
    obj_type, records, source, *,
    on_error: Literal["raise", "report"] = "raise",
) -> IngestReport
client.ingest_links(
    link_api_name, pairs, source, *,
    on_error: Literal["raise", "report"] = "raise",
) -> IngestReport

bulk_upsert and bulk_link are the engine layer and always return an IngestReport. The client methods run the complete batch first, so valid records remain committed even when another record fails. By default, a failed client batch raises IngestError; its .report is the full report and its message names the committed and failed counts. Pass on_error="report" to return the report without raising, preserving the report-returning behavior.

IngestReport — inserted_ids: list[str], errors: list[IngestError]. Records are validated against declared shape: a missing primary key, a missing required property, an unknown property, or a type mismatch yields INVALID_RECORD. Writing an ontology-owned type raises OWNED_TYPE_REFUSED; supplying an ontology-owned property raises OWNED_PROPERTY_REFUSED. A link pair whose endpoint has no live row of the declared endpoint type is rejected per pair with LINK_ENDPOINT_NOT_FOUND, like a cardinality violation; the other pairs still land, so ingest objects before their links. Re-running a link load is idempotent: a pair identical to a live link is a no-op and is still listed in inserted_ids, so the second run returns the same report as the first.

A date or datetime value is written as an ISO-8601 string or a date / offset-aware datetime object; a naive datetime object is refused with INVALID_RECORD. See Date and datetime values.


MCP server

from ontary.mcp_server import build_mcp_server

server = build_mcp_server(ontology, store, consumer, *, name=None,
                          capabilities=None)  # -> MCPServer

One server process, one Consumer identity. Declared handlers arrive pre-bound — there is no registration callback.

Twelve tools, all subject to the same guards as the Python surface. Read-only tools carry ToolAnnotations(readOnlyHint=True); execute_action carries ToolAnnotations(destructiveHint=True):

Tool Purpose Annotation
list_object_types Introspection readOnlyHint=True
list_link_types Introspection readOnlyHint=True
list_action_types Introspection, incl. parameter defs readOnlyHint=True
list_functions Introspection readOnlyHint=True
get_declarations The declared contract bundle readOnlyHint=True
get_object Single read readOnlyHint=True
query_objects Filtered/paged read readOnlyHint=True
count_objects Visible-row count readOnlyHint=True
aggregate_objects Aggregate read readOnlyHint=True
traverse_links Follow a link readOnlyHint=True
execute_action Run an action destructiveHint=True
call_function Call a function readOnlyHint=True

list_object_types includes a transitions key on every property. Its value is null when the property has no graph, or an object with the complete initial state list and moves mapping when it does. Each object type also has a rules list containing each rule's name and message; rule code is never included.

query_objects(obj_type, where=None, order_by=None, limit=None, after=None) is always bounded on the MCP surface: an omitted limit uses the server default cap of 100 rows, and an explicit limit may be at most 1000. The underlying paged read supplies an opaque next_cursor; a successful response keeps the existing rows under result and adds next_cursor alongside it (null when exhausted). Pass that cursor back with the same explicit limit to continue. after without an explicit limit returns AFTER_WITHOUT_LIMIT; values below 1 or above 1000 return INVALID_LIMIT.

The where grammar accepts a bare scalar for equality or an operator mapping using gt, gte, lt, lte, in, ne, or contains; several operators in one mapping are AND-ed, so {"gte": a, "lt": b} is a range. Operators are validated against the declared property type; unknown operators raise UNKNOWN_OPERATOR, and an incompatible operator or operand raises OPERATOR_TYPE_MISMATCH. Date comparisons use the stored ISO date order; datetime comparisons are by instant across UTC offsets. Lineage fields are not filterable, and an unknown key raises UNKNOWN_FIELD. order_by accepts a declared payload field, ascending by default, or a (field, "asc"|"desc") pair, and composes with the page cursor. count_objects returns the number of rows visible to the consumer and is not min-N-gated. It reveals only what query_objects already lists; for a min-N-released count use aggregate_objects(func="count"), which needs no value_field. aggregate_objects accepts func="mean"|"count"|"sum"|"min"|"max" with "mean" as the default; every function keeps the same min-N release discipline, including refusal of grouped-empty {} selections. value_field is optional for func="count" — omitting it counts every visible row; every other func requires it and raises INVALID_PARAMS if it is missing. traverse_links remains an unpaged list because the underlying OntologyClient.traverse/GuardedQuery.traverse API has no limit/after cursor surface to delegate to. Pass reverse=true to traverse from the link's target side and return source-side objects; identity-revealing denial is symmetric.

Objects serialize as {"payload": {...}, "lineage": {...}} (None stays None). Errors return a code from the table below; anything unclassified becomes INTERNAL_ERROR rather than leaking internals to the caller.

Needs the mcp extra.

Multi-consumer serving

from ontary.mcp_server import build_multi_consumer_mcp_server, ConsumerResolver

server = build_multi_consumer_mcp_server(
    ontology, store, *, resolve_consumer, name=None,
    capabilities=None,
    token_verifier=None, auth=None,
)  # -> MCPServer

One server process, many proven identities — no consumer argument. Builds exactly one OntologyRuntime (one GuardedQuery, one ActionExecutor, declared handlers bound once); every call takes a cheap runtime.for_consumer(...) view.

Per invocation: reads that request's MCP-verified AccessToken off the transport's own contextvar, calls the caller-supplied ConsumerResolver (resolve_consumer(token) -> Consumer | None), then stamps Consumer.principal from the token (subject, falling back to client_id) after the resolver returns — so a resolver cannot forge who authenticated. Resolution is never cached: a revoked or re-scoped token can never be served from a stale binding.

Same twelve tools as build_mcp_server, all fail-closed the same three ways, including introspection:

Condition Code
No verified AccessToken on the request (get_access_token() returns None — no token presented, or HTTP with no token_verifier configured; always true over stdio) UNAUTHENTICATED
resolve_consumer returns None for a verified principal CONSUMER_UNRESOLVED
resolve_consumer raises anything other than a deliberate OntaryError generic INTERNAL_ERROR — the raised message never reaches the caller, since it may embed token material

The SDK verifies no token and issues none — token_verifier and auth are MCP's own types (mcp.server.auth.provider.TokenVerifier / mcp.server.auth.settings. AuthSettings), configured by the deployer and forwarded VERBATIM to the underlying MCPServer(...) call — the only place either can be wired, since MCPServer exposes no public setter for either afterwards. Passing neither is a legitimate stdio-only or intentionally-open deployment; passing exactly one of the two is not a runtime state at all — MCPServer.__init__ raises ValueError (fail-fast at construction), so no server is ever built and no call is ever made. Stdio carries no auth context at all, so a multi-consumer server run that way always refuses every call with UNAUTHENTICATED regardless of these two arguments; use build_mcp_server(ontology, store, consumer) for a single-consumer stdio process instead.

Transport options belong to run()/streamable_http_app(). The builder does not force a session mode: on mcp 2.x, stateless_http, json_response, transport_security, and host are keyword arguments of run() and streamable_http_app(), and port of run() (an ASGI app binds no socket). Each request resolves its own token in stateful sessions too; tests/test_mcp_multi_consumer.py pins both modes at the ASGI boundary. build_mcp_server has one Consumer bound at construction and no per-request identity to resolve.

Needs the mcp extra.


Descriptor authoring

The lower-level surface class authoring is built on. Useful for generated or data-driven ontologies; most authors should use Ontology.

Type Key fields
ObjectTypeDef api_name, display_name, description, layer, properties, primary_key, rules, owned
PropertyDef name, type, choices, fields, transitions, required, sensitivity, scope_level
LinkTypeDef api_name, from_type, to_type, cardinality, description, identity_revealing, owned
ActionTypeDef api_name, display_name, target_type, executable_by_roles, description, parameters, capabilities
ActionParameterDef name, type, choices, fields, required, refers_to, scope_semantics
StructFieldDef name, type, choices, required
TransitionDef initial, moves
RuleDef name, message, check
FunctionDef api_name, description, input_description, output_description, parameters, capabilities
Sensitivity ai_usable, human_visible

PropertyType is Literal["str", "int", "float", "bool", "date", "datetime", "json", "struct"].

StructFieldDef describes one flat inner field. Its type is a scalar property type, choices is an optional tuple of string values for a str field, and required defaults to True. PropertyDef.fields and ActionParameterDef.fields are non-empty tuples for type="struct" and are None for every other type. The MCP schema renders each tuple as a list and always includes the fields key.

TransitionDef describes a choice property's allowed states: initial is a non-empty tuple of start states, and moves maps every choice to its allowed targets. Use an empty target tuple for a terminal state. Every state must be a declared choice. Set it on PropertyDef.transitions.

RuleDef describes a named predicate over the full new row as check: Callable[[dict[str, Any]], bool]. Its name and message must be non-empty, and names in one ObjectTypeDef.rules tuple must be unique. Rule checks should read only the supplied object. model_dump() excludes check, so the exported declaration contains only the rule's name and message.

OntologyRegistry holds the descriptors and validates cross-references; validate() raises ValidationFailed (ONTOLOGY_INVALID) on dangling link endpoints, dangling action targets, duplicate api_names, or a primary key missing from properties.

OntologyDef bundles registry + scope policy + config — the unit a client or MCP server binds to. There is deliberately no module-global registry anywhere in this SDK, so two ontologies coexist in one process with no cross-talk.

Declarations / declarations(...) expose the declared contract as data — what get_declarations serves over MCP: authority (model-declared, runtime-checked), capabilities (declared per action/function; fail-closed when unprovided or undeclared; the provider itself is unsandboxed author code), writeback (ontology writes are all ontology-owned; the runtime has no outward write path of its own — a declared convention, not an enforced boundary, since a capability provider can write outward inline), reingest (upsert-merge; owned properties survive; no deletion), visibility_default (deny-by-default — unresolved scope hides), transaction_ownership (runtime-owned — refuses caller-opened transactions), idempotency (none — retries are distinct audited attempts), audit_scope (tenant-scoped administrative view; actions always audited, functions audited iff they declare capabilities unless overridden per function, and always when a call releases a hidden field through the contributor exemption), and tenancy (one tenant per store instance, bound at construction). Includes identity: on a multi-consumer MCP server, proven by the transport, never by this runtime — a deployment with no configured verifier refuses every call rather than assuming a default identity; on a single-consumer server or in direct Python use, the Consumer is asserted by the operator at construction and nothing proves it; the verified principal (multi-consumer only) is mapped to a Consumer by a caller-supplied resolver, which is trusted author code the runtime does not sandbox; both the transport-proved principal and the resolved actor are audited, so a resolver that maps every principal onto one privileged actor is visible in the log; not every audited row has one. min_n is the one per-ontology answer, read off the ontology's own ScopePolicy.min_n.


Error codes

Every raised error carries a stable code. OntaryError is the base; ERROR_CODES: dict[str, ErrorCodeInfo] is the machine-readable registry, and the table below is generated from it.

authority

Code Meaning
AUTHORITY_ERROR Fallback code for the authority-refusal family (AuthorityError); every concrete refusal a captured write can trigger carries its own more specific code instead (e.g. SOURCE_CREATE_REFUSED, UNDECLARED_SOURCE_WRITE). It is the base for ObjectStore.capture_action_writes refusals where a write inside an action's capture context crosses the source-backed/ontology-owned line.
OWNED_PROPERTY_REFUSED A bulk_upsert record supplied a value for a property declared ontology-owned on an otherwise source-backed object type.
OWNED_TYPE_REFUSED A bulk_upsert/bulk_link record targeted an object or link type that is declared whole-type ontology-owned; no source may supply its rows.
SOURCE_CREATE_REFUSED A captured insert targeted an object type that is not declared whole-type ontology-owned (ObjectTypeDef.owned is True).
UNDECLARED_SOURCE_REMOVAL A captured retirement or link closure targeted an object or link type that is not declared ontology-owned.
UNDECLARED_SOURCE_WRITE A captured update touched a property, or a create_link targeted a link type, that is not declared ontology-owned.

conflict

Code Meaning
CALLER_TRANSACTION_REFUSED Raised when ActionExecutor.execute() (or an ingest entry point, a later task) is called while the caller has already opened a store.transaction() block (declared-contracts §3 AC9). transaction() is reentrant, so a caller-owned outer transaction could roll back an action after the executor reported success and audited ok. The engine must own the transaction/audit boundary and refuses to nest inside the caller's. Deliberately NOT audited (spec §5): an audit row inside the caller's transaction could itself be rolled back, so the refusal is raised before any audit write.
CARDINALITY_VIOLATION A link creation would violate its LinkTypeDef cardinality.
OBJECT_ALREADY_EXISTS An insert used a primary key that already has a live row of the same object type; update that object instead, or retire it first.
OBJECT_ALREADY_RETIRED A retirement targeted an object whose current row is already closed.
STORE_VERSION_UNSUPPORTED Raised at store construction when the store's schema stamp is not this engine's SCHEMA_VERSION -- a SQLite file's PRAGMA user_version, or a Postgres database's schema_meta row. Neither backend carries a migration ladder: a store written by a different ontary schema shape is REFUSED, never migrated in place and never adopted. An unstamped store that already has an objects table is refused for the same reason -- stamping a shape this engine cannot read would be a lying stamp, and every later query would fail as a confusing uncoded SQL error instead. The message names BOTH the store's and the engine's versions, so an operator knows exactly what to upgrade; the way forward is a matching ontary version, or a fresh store the data is migrated into.
STORE_BUSY A SQLite transaction could not acquire or retain its database lock within ObjectStore's configured busy timeout; retry after the competing writer finishes or increase busy_timeout. This is a conflict, not a precondition: retrying is the remedy, and the kind travels on the MCP wire so callers can branch on retryability.

internal

Code Meaning
INTERNAL_ERROR An unclassified failure the MCP surface refuses to describe further, to avoid leaking internals to the caller.
STORE_ERROR Fallback code for an unclassified store-layer error.

permission

Code Meaning
PERMISSION_DENIED The consumer's role is not permitted to execute the action (code PERMISSION_DENIED). The permission kind also covers scope refusals under SCOPE_DENIED; each raise site supplies the specific code.
SCOPE_DENIED The consumer's scope does not cover the action's declared target/scope parameter (code SCOPE_DENIED). Role refusals use PERMISSION_DENIED; both are kind permission and each raise site supplies the specific code.
UNAUTHENTICATED A request carried no verified identity at all -- kind permission. build_multi_consumer_mcp_server raises this for every tool call, including introspection, that reaches it with no authenticated AccessToken: stdio (which has no auth context) or HTTP with no token_verifier configured. The fix is: configure authentication. It is deliberately separate from CONSUMER_UNRESOLVED: a missing credential; mapping the principal fixes a missing consumer, so callers can distinguish the two from .code alone.
CONSUMER_UNRESOLVED A verified principal existed, but the author's resolve_consumer callback returned no Consumer for it -- kind permission. build_multi_consumer_mcp_server raises this when the callback returns None for an otherwise verified AccessToken; the fix is: map this principal. It remains distinct from UNAUTHENTICATED, where no credential was presented at all, so the two failures are distinguishable from .code alone.

precondition

Code Meaning
CAPABILITY_NOT_PROVIDED A declared capability had no provider bound for this call.
CLOCK_CONFLICT A store already has a different clock installed; a store has one clock. Bind with the same clock object, or with no clock to use the one already installed.
CLOCK_REGRESSION The store clock reads earlier than the valid_from of the version a write would close; the clock went backwards. Fix the clock (it must never run behind the data it wrote) and retry.
FUNCTION_ERROR Registering/calling a Function failed: undeclared api_name, duplicate registration, or no handler bound.
PRECONDITION_FAILED An action's precondition failed; the message names it. The conventional code for ActionError (kind precondition); an author may attach their own stable code instead (AC7), e.g. raise ActionError("...", code="GAP_NOT_ACKNOWLEDGED"). It is also used with overridden codes for unregistered/unhandled actions (UNKNOWN_ACTION) and parameter-validation failures (INVALID_PARAMS) -- see the code= overrides at those raise sites.
TRANSITION_NOT_ALLOWED A governed property changed to a state not allowed by its declared transition graph; action starts must be initial states.

validation

Code Meaning
CLOCK_NOT_TIMEZONE_AWARE A clock returned a naive datetime; an instant must be timezone-aware. Return datetime values with a tzinfo, such as datetime.now(timezone.utc).
AFTER_WITHOUT_LIMIT GuardedQuery.get_objects's (or OntologyClient.list's) after was given without limit (pagination-hardening T2 review P1) -- the unpaginated Store.read_all path has no page to resume, so ignoring after would let a caller that lost track of its limit silently re-read every visible row and duplicate work; a caller that genuinely wants everything passes no after at all.
INVALID_BATCH Store.read_page's batch was < 1 (SQLite's LIMIT -1 means unlimited and InMemoryStore's negative slice drops rows -- both the opposite of a bounded read).
INVALID_CURSOR Raised when Store.read_page's after_key is malformed OR simply unknown. after_key is UNTRUSTED input: it reaches the store from an MCP client via a later page-filling loop, round-tripped from a previous page's cursor without any guarantee the caller did not tamper with it. As amended 2026-07-25 (T2 review, spec §5), it is a random per-row PAGE TOKEN (objects.page_token, uuid4 hex), not a decimal row id. Resolving token to row id through the unique index is the ONLY way to turn a cursor into row identity, so every string never issued for a real row (malformed, tampered, or made up) raises this same error on both backends. There is no distinct well-formed but out-of-range case from the old integer design's OverflowError/silent-empty-page divergence. A token issued for a row since superseded by update still resolves because lookup uses row_id independently of valid_to, so an in-flight cursor remains a valid resume point (spec §8).
GROUP_KEY_COLLISION Two distinct group_by values in one selection release as the same dictionary key, so one cell would have to describe two populations. The released shape is dict[str, ...] -- a public return type and MCP's wire shape -- and str() is not injective over the values a group key can take: an optional property keys None on the rows that lack it, which collides with a row carrying the literal string "None". The populations did not merge; the later one overwrote the earlier, so the released value (and, under func="count", the released size) described whichever rows were inserted last, decided by nothing the caller supplied or could observe. Raised per group as each is released, AFTER that group's min-N check, so the release floor keeps precedence over a shape refusal.
INVALID_GROUP_BY GuardedQuery.aggregate_by's (or BoundQuery's/OntologyClient's) group_by cannot be a group key. Either it was falsy (e.g. "") -- the shared aggregation body branches on group_by's truthiness, so a falsy-but-non-None value would otherwise silently collapse to the ungrouped path and return a float instead of a dict[str, float] -- or it names a property whose declared PropertyType is not groupable (json, whose values may be a dict or list and so need not be hashable; grouping by one used to raise a bare TypeError from inside the grouping loop, and INTERNAL_ERROR once it crossed the MCP boundary). The declared type is checked, not the stored values, so a json column that happens to hold only scalars refuses too rather than working until the first dict arrives. Both are checked in aggregate_by, where the GuardedQuery, BoundQuery, and client surfaces converge, before _aggregate runs, rather than relying on an assert removed by python -O.
A struct property is also not groupable; using it as group_by raises this code before rows are read.
PAGE_NOT_ITERABLE A Page/TypedPage was iterated, indexed or measured directly instead of through .items. Both are pydantic models, so the inherited BaseModel.__iter__ would otherwise yield (field_name, value) pairs -- for row in page hands back ('items', [...]) and ('next_cursor', ...), and the failure surfaces later as AttributeError: 'tuple' object has no attribute 'payload' at whatever touched the row. This refuses at the iteration itself and names .items and limit=None.
INVALID_LIMIT GuardedQuery.get_objects's (or OntologyClient.list's) limit was < 1 -- a silently empty page would hide that the call was malformed rather than legitimately paginated.
STALE_CURSOR An ordered walk's cursor resolved to a row that is no longer current; restart the ordered walk from the first page.
INVALID_PARAMS A call's parameters failed declared-shape validation: an action's params, or a read parameter whose SHAPE is wrong -- an order_by that is neither a field name nor a (field, direction) pair, or a where= that is not a mapping of field name to condition. A parameter naming something that does not exist is UNKNOWN_FIELD instead; this code is about the shape, not the name.
INVALID_RECORD A bulk_upsert record failed declared-shape validation (missing primary key, missing required property, unknown property, or a value that does not match its declared type). The validation kind carries the SAME INVALID_RECORD code that bulk_upsert already reports: from a caller's point of view, a record not matching the declaration is one failure regardless of which write path noticed. This closes the M9 hole where only ingest checked: Store.insert/update and therefore ActionContext.insert/update could commit a row missing a required property or carrying a wrong-typed value, report success, and leave the typed reader unable to hydrate it. The same code wraps a Pydantic ValidationError while hydrating a stored OntologyObject payload (for example, a non-ISO datetime string), never surfacing a bare traceback; a stored row failing declared-shape validation on read-back is the same failure class ingest carries on write. Ontology.diagnose(store=...) reports, per type and property, the stored rows that would fail hydration under the current ontology, and Ontology.validate(store=...) raises this code for them.
LINK_ENDPOINT_NOT_FOUND A link creation named an endpoint id with no live row of the link type's declared endpoint type -- missing or retired; a link needs a live object at both ends.
LINK_NOT_FOUND A link closure found no matching live link.
NON_NUMERIC_AGGREGATE GuardedQuery.aggregate's value_field is declared a non-numeric PropertyType (anything other than int/float, such as str/json/datetime/bool). It is checked against the declared type before rows are iterated or coerced, so values that merely look numeric cannot bypass the type contract (spec m35-sdk-refactor §6 AC7). func="count" is exempt and accepts any declared type.
OBJECT_NOT_FOUND An update targeted a non-existent object.
OBJECT_NOT_LOADED ActionContext.save got an object this action context did not hand out; load it with ctx.get(...) or ctx.create(...) first, so only the fields the handler changed are written.
OBJECT_RETIRE_NOT_FOUND A retirement targeted an object with no stored row.
PRIMARY_KEY_IMMUTABLE An update tried to change an object's primary key; a primary key is immutable, so retire the object and insert a new one instead.
RULE_VIOLATED A declared object rule returned false or raised while checking the full new row.
ONTOLOGY_INVALID A declaration was rejected: validate() found invalid cross-references, or an authoring call (@ontology.object(...), ontology.link(...), .definition) refused a kwarg of the wrong shape: a misspelled scope/cardinality literal, a rule not wrapped in a list, a non-callable row_visibility, empty scope_levels, or min_n below 1.
SCOPE_POLICY_ERROR A ScopePolicy declaration is unusable: a rule references an undeclared object type, link type, or scope level; a type declares an empty contributor rule list; a type is listed in unscoped_types while also declaring scope rules; or an action's scope parameter refers to an unscoped type.
UNDECLARED_CAPABILITY A handler requested a capability its action or function did not declare.
UNDECLARED_EVENT An action emitted an event type it did not declare.
EVENT_SUBJECT_INVALID An emitted event's subject could not be resolved to a valid target object.
UNKNOWN_ACTION An action name is unregistered on the OntologyRegistry, or has no handler bound to it.
UNKNOWN_FIELD A typed get/list call named a key that is not one of the target class's declared properties (spec typed-authoring AC7). The existence-only check runs client-side before the guarded read layer; a hidden-but-declared key still reaches the visibility kind unchanged, and the string-form surface keeps its silent-non-match behavior (AC8). The error lives here since C3 of the staged refactor (previously ontary.functions, which re-exports it).
UNKNOWN_LINK_TYPE An operation referenced an unregistered link type.
UNKNOWN_NAME A typed BoundQuery/OntologyClient call named an unregistered object, link, action, or function -- e.g. an undecorated class, a class/LinkHandle registered on a different Ontology, or a link api_name absent from this registry (typed-authoring AC7 / typed-actions AC8). Typed lookup failures use the validation kind and live here since C3 of the staged refactor so ontary._typed_api can raise them below the runtime modules.
UNKNOWN_OBJECT_TYPE An operation referenced an unregistered object type.
UNKNOWN_OPERATOR A mapping-form where clause named an operator outside the declared set: gt, gte, lt, lte, in, ne, or contains -- or was an empty mapping. A mapping with several operators is validated key by key, so one unknown key refuses the whole clause.
OPERATOR_TYPE_MISMATCH A mapping-form where operator is not valid for the property's declared type (comparisons need int, float, date, or datetime; contains needs str), or its operand is not a declared-type scalar.
A where condition on a struct property raises this code with a message such as where is not supported on struct property 'amount'; inner-field paths are unsupported.

visibility

Code Meaning
MIN_N_VIOLATION An aggregate would be computed over fewer than min_n distinct contributors.
VISIBILITY_DENIED A single-object read/write targeted an object outside the consumer's scope.

57 codes across 7 kinds.


Exception hierarchy

The public kind-class surface is this nine-class hierarchy. IngestError is an additional coded OntaryError subclass used for per-record and client-level ingest failures; its code follows the catalogued failure in the report. Every error carries a stable code from ERROR_CODES. Catch a specific kind with except <KindClass> as e: e.code; except OntaryError as e: e.code catches every coded error, including IngestError.

Exception Parent Raised when
OntaryError Exception Root of all coded errors; use it to catch every coded error.
VisibilityError OntaryError A visibility rule refuses a read/write or hidden-field operation, or an aggregate has fewer than min_n distinct contributors.
PermissionDenied OntaryError The caller lacks the required role, scope, or authenticated identity.
PreconditionFailed OntaryError A required operation or action precondition is not satisfied.
ValidationFailed OntaryError Caller input, declarations, records, or other values fail validation.
AuthorityError OntaryError A source or caller attempts a write outside its declared authority.
ConflictError OntaryError The requested operation conflicts with store, ontology, schema, or link state.
InternalError OntaryError The engine has no more specific coded classification for the failure.
ActionError PreconditionFailed An action handler's precondition fails; pass code="PRECONDITION_FAILED" or your own stable code.
IngestError OntaryError A per-record or client-level ingest failure carrying the report failure's stable code.