ontary — API reference¶
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
- Engine surface
- Authoring an ontology
- Scope policy
- Runtime and clients
- Reading
- Actions
- Functions
- Capabilities
- Security
- Stores
- Bulk ingest
- MCP server
- Descriptor authoring
- Error codes
- Exception hierarchy
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 itstarget=is refused at@ontology.action(...)withONTOLOGY_INVALID, naming the action, the parameters, and both types. Before, the scope gate checked the parameter's object while the audit entry and MCP namedtarget=. Onetarget()parameter must refer to thetarget=type; extratarget()parameters of other types are allowed.validate()anddiagnose()apply the same rule to a hand-builtOntologyRegistry. - 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 withStore.read_alland returns oneINVALID_RECORDfinding per type and property, with the number of rows that would fail hydration.validate(store=store)raisesINVALID_RECORDfor them. The sweep reads every row, so it runs only when you pass a store, never atbind().
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—Truemarks the whole type ontology-owned (no source may write it); adictmarks 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—Truedeclares 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
sensitivitymust be declaredX | None. The decorator raises a coded validation error at class-registration time otherwise — because a redacted read has to be able to returnNonefor 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
Enummember or its value:ingest,ctx.create,ctx.save, action parameters, andwherefilters. Any other value is refused (INVALID_RECORDon a write,INVALID_PARAMSon an action). Awherefilter does not refuse a value outside the choices; it matches no rows. - Only where
choicesare declared. AnEnummember is unwrapped to its value by the declaration, not by its Python type. Aprop(choices=...)property accepts a member whose value is one of its choices, because it is the same declaration. A plainstrproperty withoutchoicesstill refuses anEnummember (INVALID_RECORDon a write,OPERATOR_TYPE_MISMATCHin awherefilter), as it did before. - Reads. A typed read returns the
Enummember, or theLiteralstring, somypynarrows the field. A dict read, an MCP result, and anaggregate_bykey return the plain string. - Action parameters. The same annotations on an
ActionParamsfield setchoiceson itsActionParameterDef, and the typed handler receives the member.prop(choices=[...])on astrparameter setschoicestoo, as on a property, and the handler receives the string. - MCP.
list_object_typesandlist_action_typeslist the allowed values underchoices(nullwhen a property or parameter has none). - Refused at declaration (
ONTOLOGY_INVALID): a member that is not a string, such as anIntEnumorLiteral[1, 2]; a choice annotation combined withprop(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, defaultNone) is the URL of the section of the published English design guide that explains the finding. It isGUIDE_URL + "#" + anchor, built fromontary.diagnose.GUIDE_URLandontary.diagnose.GUIDE_ANCHORS. Every code inontary.diagnose.ADVISORY_CODESsets it. Findings with no guide section (ONTOLOGY_INVALID,SCOPE_POLICY_ERROR,INVALID_RECORD,RULE_VIOLATED,DIAGNOSE_RULE_FAILED) leave itNone.acceptsays "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 indiagnose(), in theontary validatetext, 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, thevalid_from/valid_toof every row and link the action writes (including linksretirecloses), and thetsof every audit entry of the invocation (ok, denied, error). CLOCK_CONFLICT. Binding the same store with a different clock object raisesPreconditionFailed. The same clock object, or noclock=, is fine; a runtime bound withoutclock=uses the store's installed clock.CLOCK_REGRESSION. A write whose instant is earlier than thevalid_fromof the row or link it closes raisesPreconditionFailed. 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 naivedatetimeraisesValidationFailed; 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 realdatetime(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
Noneon the typed surface, with its name inredacted_fields. On the string and MCP surfaces the key is absent frompayloadentirely — there is noredacted_fieldscompanion there. Read defensively over MCP (payload.get("email"), notpayload["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
limititems 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 Nonemeans 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. Sowhile next_cursor is not Nonealways 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.
afterwithoutlimit→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 anupdatethat supersedes it — and raisesSTALE_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_idCapabilityAccessRecord—api_name,countEmittedEvent—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:
- Scope visibility — resolved through the declared
ScopePolicy; an unresolved level denies. - Sensitivity redaction —
Sensitivity(ai_usable, human_visible)per property. A hidden field reads backNoneand is named inredacted_fields. - min-N — aggregates over fewer than
min_ndistinct contributors raiseVisibilityErrorwith codeMIN_N_VIOLATION. Contributors come from the declaredcontributorrules, so repeated rows from one person do not clear the bar. The count is taken over the rows that carryvalue_field, not every row in the selection — a group ofmin_npeople 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. - 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:
SelfScopeanswers with the object's own id, which retirement does not take away.DirectPropertyreads the scope key off the payload, which retirement leaves alone: this gate reads the newest row rather than the live one.ViaLinkclimbs thelinkstable. 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.CustomResolveris author code handed the rawStore, 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 readsread_current, which isNonefor a retired object, so a resolver written that way denies. A type that needs the precondition refusal after retirement declares a second rule — aDirectPropertyon 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 thepostgresextra.
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
ZstaysZ.eqandinfilters 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
datetimeobject is refused, because addingtzinfo=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
ValidationFailedwithINVALID_RECORDonStore.insert/update,ActionContext.create/save, and per record in an ingest report. An action parameter is refused withINVALID_PARAMSbefore the handler runs. Awhereoperand is refused withOPERATOR_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. |