Changelog¶
Notable changes to ontary. Format loosely follows
Keep a Changelog; versioning follows
docs/compatibility.md — pre-1.0, so a minor bump may break
you.
ontary continues ontos, authored at Atrae, Inc. and released to the author on
2026-09-06 for open-source development under MIT. Entries below [0.10.0] describe
ontos releases; their tags exist here as renamed snapshots of the same source.
[Unreleased]¶
Added¶
MCPServeris importable from the package root (from ontary import MCPServer), so code can annotate whatbuild_mcp_serverreturns (#61). It is themcpSDK's class and needs the[mcp]extra.import ontarystill works without the extra; touchingMCPServerraises the builders' install hint, and in a core-only install so doesfrom ontary import *.__all__now has 46 names.docs/testing.md(EN and JA) has a "Testing an MCP server in-process" section: drive the server throughhttpx2.ASGITransport, because a syncTestClientruns the app on another thread and the SQLite store then returnsINTERNAL_ERRORfor every tool call (#61).docs/mcp-serving.mdhas a complete authenticated multi-consumer example: a stand-inTokenVerifier, realAuthSettings, onetools/callrequest with its JSON response, and the401a missing or unknown token gets (#56). A doc test runs the program and replays the shown requests in-process, so the page failsmake verifywhen it stops matching ontary ormcp.
Removed¶
- Dict-form Function handlers on
@ontology.functionandFunctionRegistry(#103), deprecated in 0.19.0. Declare aFunctionParamssubclass, or take only(query); any other handler raisesValidationFailedONTOLOGY_INVALIDat declaration. FunctionRegistry.registerno longer takesmode(#103).FunctionDef.parametersno longer acceptsNone, and MCPlist_functionsnever publishesparameters: null(#103). A no-input function has[].
Changed¶
- The tickets example drops the stored
Ticket.escalatedflag and derives it withisTicketEscalated.OpenEscalationis merged intoEscalateTicket, andResolveTicket/ArchiveTicketare renamed toResolveEscalation/ArchiveEscalation. client.call_functionraisesValidationFailedINVALID_PARAMS(wasTypeError) for an argument that is neither a function name nor aFunctionParamsinstance (#103).- The public
FunctionHandleralias is nowCallable[..., Any], a(query)or(query, params)handler; it wasCallable[[BoundQuery, dict[str, Any]], Any](#103). FunctionRegistry.function(api_name, params_cls=None)takes an optional params class, matchingFunctionRegistry.register(#103).- The API reference states the
date/datetimewrite format in one place, "Date and datetime values", and links it fromStore.insert/update,ActionContext.create/save, bulk ingest, and action parameters (#57).
[0.19.0] — 2026-10-04¶
uv add "ontary @ git+https://github.com/ryoochi0112/ontary@v0.19.0"
This release completes M1, "Model your operation". Actions can record events,
the business facts they produce, in their own transaction. Function parameters
are typed like action parameters. An injectable clock stamps every write, and
ctx.now() reads it. Lints now name the concrete fix and link to the design
guide, and accept= silences a lint at the declaration that caused it.
The store schema moves to v14, so a 0.18.0 store must be dropped and
re-ingested. Dict-form Function handlers are deprecated and are removed in
0.20.0.
Added¶
EVENT_NEVER_EMITTEDlint (#47). Warns when no action declares an event inemits; the event'saccept=can suppress it.- Events (#47).
@ontology.eventdeclares a business fact as anEventsubclass. An action lists the events it may emit withemits=[...], andctx.emit(event, *, about=None)records one inside the action's transaction. The subject defaults to the action's target.client.events(event_type=None, /, *, about=None, since=None, until=None)reads them asEventRecord, governed by the subject's latest scope androw_visibility, withSensitivityredaction. AnokAuditEntrylists its emitted events inAuditEntry.events, andScenario.then_eventchecks them in tests.EventandEventRecordjoin the root exports. ActionTypeDef.emits(#47). It lists the api names of the events an action may emit. MCPlist_action_typesshows them asemits.- Event error codes (#47).
UNDECLARED_EVENTreports an action emitting an event type outside itsemitsdeclaration;EVENT_SUBJECT_INVALIDreports an event whose subject cannot be resolved to a valid target object. - Typed Function parameters (#45). A
FunctionParamssubclass validates a function's inputs, types the handler, and exposes its parameter definitions through MCPlist_functions. A(query)handler declares a function with no inputs, andcall_function(api_name)no longer needs a params argument. ctx.now()and a store-level clock (#46).ontology.bind(store, clock=...)installs the clock on the store, and it stamps every write: actions,bulk_upsert/bulk_link, and direct store calls. One action invocation reads the clock once, and that instant is used forctx.now(), everyvalid_from/valid_toit writes, and every auditts.- Clock error codes (#46).
CLOCK_CONFLICT(binding a store with a second clock),CLOCK_REGRESSION(a write earlier than thevalid_fromit closes), andCLOCK_NOT_TIMEZONE_AWARE(a clock returned a naivedatetime). - Two new advisory lints (#50).
FREE_TEXT_STATUSfires on astatusor*_statusproperty typedstrwith no declared choices.AUDIT_TYPEfires on an object type name ending inAuditLog,AuditEntry,AuditTrail,AuditRecord, orAuditEvent. Finding.guide(#50). It holds the URL of the design-guide section that explains the finding, orNone. All nine advisory codes set it.ontary.diagnoseexportsGUIDE_URL,GUIDE_ANCHORS, andADVISORY_CODES, andontary validateprints aguide:line underfix:and aguidekey in--json.accept=onprop,@ontology.object,@ontology.action, and@ontology.function(#50). It accepts a modelling lint at the declaration that caused it. A code the declaration cannot accept is refused withONTOLOGY_INVALID.snapshot=Trueon@ontology.object(#50). It declares a snapshot type, which gets noSTORED_DERIVABLEfinding and noFORBIDDEN_TYPE_NAMEfinding for aSnapshotsuffix.ontary validate --strict(#50). It also exits 1 when anywarnfinding remains.infofindings never change the exit code.
Changed¶
- Breaking (store schema):
SCHEMA_VERSIONis now 14.audit_loghas a neweventscolumn. A store stamped 13 is refused withSTORE_VERSION_UNSUPPORTED; drop and re-ingest it as described in docs/storage.md. - Lint messages and fix hints changed (#50). They now name the concrete fix, using your own declaration names. The codes and severities did not change.
CRUD_ACTION_NAMEnow covers Functions as well as actions (#50). It compares the first word of the api name, ignoring letter case, soSettleInvoiceno longer warns.FORBIDDEN_TYPE_NAMEnow catches a year suffix such asSurvey2024(#50). A type named exactlyHistoryorSnapshotno longer fires, because it is not a clone of another type.- The text "declared snapshot" in an object's
descriptionno longer exempts the type fromSTORED_DERIVABLEorFORBIDDEN_TYPE_NAME(#50). Usesnapshot=True. - In the tickets example,
Ticket.statusis now a choice property, soontary validate --strictpasses on it (#50). - Audit
tsis now the instant the invocation started (#46). It was read after the handler finished. Bind the clock before seeding data, or a clock set in the past can raiseCLOCK_REGRESSIONon the first update.
Deprecated¶
- Dict-form Function handlers (#45). They remain supported in 0.19.0 with a
declaration-time
DeprecationWarningand are removed in 0.20.0; use aFunctionParamssubclass.
Fixed¶
prop(choices=[...])on anActionParamsfield now setschoiceson itsActionParameterDef, as it does on a property (#90). Before, it was silently ignored, so the parameter accepted any string. Combining it with anEnumorLiteralannotation, or declaring invalid choices, is refused asONTOLOGY_INVALID, as on a property.- A
(str, Enum)mixin member used as a primary key or as an id argument now keys the object by its string value ('a') on every store (#91). Before, the in-memory and Postgres stores keyed it bystr()('Mixin.A'),insertreturned'Mixin.A'on all three, and re-ingesting the row withbulk_upserton SQLite raised a rawIntegrityError. EveryStoremethod andbulk_upsert/bulk_linknow reduce astrsubclass to its plain value. Postgres rows already stored under a'Mixin.A'-style id are not rewritten.
[0.18.0] — 2026-09-28¶
uv add "ontary @ git+https://github.com/ryoochi0112/ontary@v0.18.0"
This release lets a model say what its values may be and how they may change.
Choice properties (Enum and Literal) and struct properties are typed on read
and checked on write. Declared status transitions and named object rules are
enforced on every write path. The string ActionContext members deprecated in
0.17.0 are removed; use the typed members. The store schema stays at v13, so a
0.17.0 store needs no re-ingest.
Added¶
- Choice properties (#42). A property or action parameter annotated with a
string-valued
Enumor aLiteral[...]of strings declarestype="str"with its member values aschoices. The store keeps the string value, every write path accepts the member or its value, and a typed read returns the member, somypynarrows it.ActionParameterDefgainschoices, enforced asINVALID_PARAMS, and MCP'slist_object_typesandlist_action_typesnow listchoicesfor properties and parameters. A non-string member, a choice annotation combined withprop(choices=...), and a choice annotation on the primary key are refused asONTOLOGY_INVALID. AnEnummember is unwrapped to its value by the declaration, in one place (typesys.choice_value), before validation and before the object id is read: only a property or parameter that declareschoicesaccepts it. The one deliberate behaviour change for an existing declaration is that aprop(choices=...)property (including achoices-declared primary key) now accepts anEnummember whose value is one of its choices, since it is the same declaration. Astrproperty withoutchoicesrefuses anEnummember exactly as before. - Struct properties and action parameters (#43). A flat Pydantic model
annotation declares
type="struct"with its inner fields exposed throughPropertyDef.fieldsorActionParameterDef.fields.StructFieldDefdescribes each inner field. MCP schema discovery includesfields(null for non-structs). An absent optional inner field is stored as explicitnull, so a dict write hydrates like the equivalent model instance. Struct inner fields may only default toNone. This additively widensPropertyType;SCHEMA_VERSIONis unchanged. - Declared status transitions and named object rules (#44). Choice properties
can carry a
TransitionDefgraph, andOntology.ruleregisters a typed predicate over the full object. The write paths enforce allowed state moves and rule outcomes withTRANSITION_NOT_ALLOWEDandRULE_VIOLATED. These declarations do not changeSCHEMA_VERSION.
Changed¶
- Every store update now reads the current row inside its transaction before
applying an update. MCP
list_object_typesadds atransitionsgraph for each governed property and aruleslist for each type.RuleDefcarries a Python callable, excluded frommodel_dump(). list[<BaseModel>]anddict[..., <BaseModel>]annotations now raiseONTOLOGY_INVALIDwith the fix "use a linked object type". Previously they derivedtype="json";prop(property_type="json")does not bypass the refusal.- A model annotation with an explicit
prop(property_type=...)other than"json"now raisesONTOLOGY_INVALID.property_type="json"still stores the model opaque. - A
RootModelannotation now raisesONTOLOGY_INVALID, including with an explicitproperty_type="json". An unadornedRootModelwas already refused before #43; a property with an explicit JSON override previously accepted it.
Removed¶
- Removed
ActionContext.insert(obj_type, payload); usecreate(cls, **values). - Removed
ActionContext.update(obj_type, obj_id, changes); useget(cls, obj_id), assign the changed properties, and callsave(obj). - Removed
ActionContext.create_link(link_api_name, from_id, to_id); uselink(handle, from_, to). - Removed
ActionContext.read_current(obj_type, obj_id); useget(cls, obj_id). - Removed
ActionContext.read_all(obj_type); useall(cls). - Removed
ActionContext.links_from(link_api_name, from_id); usetraverse(handle, anchor). - Removed
ActionContext.links_to(link_api_name, to_id); usetraverse(handle, anchor, reverse=True). - Passing a
stras the first argument toretireorunlinkraisesValidationFailedwith codeINVALID_PARAMS. Useretire(obj)orretire(cls, obj_id), andunlink(handle, from_, to). ActionContext.unlinkis positional-only, so a keyword call raisesTypeError; the typed overload was already positional-only. See ontary#41.
[0.17.0] — 2026-09-27¶
uv add "ontary @ git+https://github.com/ryoochi0112/ontary@v0.17.0"
This release makes action handlers typed. A handler reads and writes through the
ontology's own classes and link handles, and mypy checks every field it touches.
The string ActionContext members still work but warn, and they are removed in
0.18.0. Every non-ok audit entry now records its target and error code. The store
schema moves to v13, so a v12 store must be re-ingested.
Added¶
- A typed
ActionContextsurface.ctx.get(Order, id) -> Order | None,ctx.all(Order),ctx.create(Order, **values) -> Order, andctx.save(order)read and write through the ontology's own classes.savewrites only the declared properties changed since the context handed the object out.ctx.link,ctx.unlinkandctx.traversetake aLinkHandlewith each end as an object or its id, andctx.retire(order)orctx.retire(Order, id)retires.mypychecks the class, the returned type, each link endpoint's type, and every attribute assigned beforesave. The new codeOBJECT_NOT_LOADED(kindvalidation) refusessaveon an object this context did not hand out.
Deprecated¶
- The string
ActionContextmembers now emit aDeprecationWarningthat names the typed replacement:insert→create,update→get+save,create_link→link,read_current→get,read_all→all, andlinks_from/links_to→traverse. The string forms ofretireandunlinkwarn as well; their typed forms keep the same names.retire's own link cascade does not warn. The string members are removed in 0.18.0 (ontary#41). The README, the docs, andexamples/ticketsnow use the typed members.
Changed¶
- Breaking (store schema):
SCHEMA_VERSIONis now 13.audit_loghas a newerror_codecolumn. A store stamped 12 is refused withSTORE_VERSION_UNSUPPORTED; drop and re-ingest it as described in docs/storage.md.
Fixed¶
- Every non-
okaudit entry now says what failed and why. An action entry records the requestedtarget_idon every outcome; before, only theokentry had it, anddeniedanderrorentries wroteNone. The newAuditEntry.error_coderecords the raised exception's code on everydeniedanderrorentry, for actions and functions alike. An exception without a code is recorded asINTERNAL_ERROR, as the MCP server reports it. Fixes ontary#49.
[0.16.0] — 2026-09-27¶
uv add "ontary @ git+https://github.com/ryoochi0112/ontary@v0.16.0"
This release completes M0, "Trust the core". A link needs a live object at both
ends, and re-creating a link is a no-op. An action may target an unscoped type.
datetime objects are accepted on write, and authoring mistakes raise catalogued
errors. diagnose() catches a target() mismatch and stored rows that an edited
ontology can no longer read. The store schema moves to v12, so a v11 store must be
re-ingested.
Changed¶
- Breaking (store schema):
SCHEMA_VERSIONis now 12.audit_loghas a newunscoped_paramscolumn. A store stamped 11 is refused withSTORE_VERSION_UNSUPPORTED; drop and re-ingest it as described in docs/storage.md. - An action may now target a
scope="unscoped"type. Atarget(...)parameter that refers to an unscoped type skips the action scope gate, so the action'sroles=is its only gate. Before, every scope level resolved to nothing and every consumer was denied withSCOPE_DENIED, so reference data could never be an action's target. The newAuditEntry.unscoped_paramsnames the parameters that skipped the gate. Ascope_ref(...)parameter may not refer to an unscoped type;validate()anddiagnose()report it asSCOPE_POLICY_ERROR. Fixes ontary#35. - Breaking (behaviour): a link now needs a live object at both ends.
Store.create_link, and thereforeActionContext.create_linkandclient.ingest_links, refuse an endpoint id that is missing, retired, or live only as another type with the new codeLINK_ENDPOINT_NOT_FOUND(kindvalidation), checked before cardinality.ingest_linksreports it per pair and still commits the other pairs. Before, such a link was stored dangling. Ingest objects before their links. Fixes ontary#36. create_linkis now idempotent for every cardinality: a link identical to a live one is a no-op (nothing written, noWriteRecord), checked before cardinality. Before, a MANY_TO_MANY re-create inserted a duplicate row and a MANY_TO_ONE re-create raisedCARDINALITY_VIOLATIONagainst itself; re-runningingest_linksis now idempotent and returns the same report. The store schema gains a partial unique indexidx_links_live_pairon live links as a storage backstop;SCHEMA_VERSIONstays 12, which this release introduces, so a store created from an unreleased 12 lacks the index but is still guarded by the engine check. Fixes ontary#37.
Fixed¶
validate()anddiagnose()now catch two modelling mistakes that used to pass and fail later. An action whosetarget(...)parameters all refer to another type than itstarget=is refused at@ontology.action(...)withONTOLOGY_INVALID; before, the scope gate checked the parameter's object while audit and MCP namedtarget=.OntologyRegistry.validate()anddiagnose()apply the same rule to hand-built registries, so a hand-built action with that shape that validated before is now refused.Ontology.diagnose(store=...)andOntology.validate(store=...)sweep a store's current rows and report, per type and property, the rows that would fail hydration under an edited ontology (INVALID_RECORD); before, the store opened cleanly and the first read failed. Fixes ontary#40.- Authoring mistakes are refused where they are written, with
ValidationFailed(ONTOLOGY_INVALID) naming the class or link, the kwarg, what was given, and the accepted forms. Before, a misspelledcardinalityescaped as a bareValueErrorfromontology.link(...), and a misspelledscope="unscoped", a rule not wrapped in a list, or a non-callablerow_visibilitysurfaced only atvalidate()as a raw pydantic error from insideScopePolicy. An empty or duplicatedscope_levelsand amin_nbelow 1 are wrapped the same way when.definitionis built.cardinalityandscopeare nowLiteral-typed, so the typo is a mypy error as well; the runtime still accepts any string that names a member. Fixes ontary#39. - An offset-aware
datetimeobject is now accepted wherever adatetimeproperty or parameter takes a value:Store.insert/update(and soActionContext),bulk_upsert/client.ingest,whereoperands, and action parameters in both the typed and the dict form. It is persisted as its ownisoformat()spelling, offset preserved, soeqkeeps matching what was written and the typed read hydrates it back to an equaldatetime. A naivedatetimeobject is refused with a message that names the problem (INVALID_RECORD,INVALID_PARAMS, orOPERATOR_TYPE_MISMATCHby path). Before, everydatetimeobject was refused with "expected type 'datetime', got datetime", and a naive one slipped through the typed action form as a naive string. ISO strings behave exactly as before. Adateobject in a dict-form action parameter is likewise converted instead of failing the JSON round-trip check. Fixes ontary#38. docs/api-referencelistedPropertyTypewithout"date"; the literal now matches the code.
[0.15.0] — 2026-09-27¶
uv add "ontary @ git+https://github.com/ryoochi0112/ontary@v0.15.0"
A primary key now identifies exactly one live object. insert refuses a live
duplicate and update refuses a primary-key change, each with a new catalogued
code. This bumps the store schema to v11, so a v10 store must be re-ingested.
Changed¶
- Breaking (store schema):
SCHEMA_VERSIONis now 11. The newidx_objects_live_idpartial unique index allows at most one live row per(tenant, object_type, id)in SQLite and Postgres. A store stamped 10 is refused withSTORE_VERSION_UNSUPPORTED; drop and re-ingest it as described in docs/storage.md.
Fixed¶
insert(andctx.insertinside an action) now refuses a primary key that already has a live row of the same object type, withConflictErrorand the new catalogued codeOBJECT_ALREADY_EXISTS. Before, every store accepted the duplicate:read_allreturned both rows whileread_currentkept returning the old one. A retired object's id may still be inserted again. Fixes ontary#34.update(andctx.updateinside an action) now refuses a change that gives the primary key a different value, withValidationFailedand the new catalogued codePRIMARY_KEY_IMMUTABLE. Before, every store accepted it: the payload primary key changed while the store id kept its old value, so two live objects could carry the same primary key andread_current(type, "b")could return a row whose payload saidid == "a". Repeating the current value is still accepted. To re-key an entity, retire it and insert a new object. Fixes ontary#75.- An ungrouped
aggregatethat misses min-N with a suppliedvalue_fieldno longer reports<type>.<field> group None; only a realgroup_bynames a group in theMIN_N_VIOLATIONmessage. Review backlog from ontary#27.
[0.14.0] — 2026-09-14¶
uv add "ontary @ git+https://github.com/ryoochi0112/ontary@v0.14.0"
One ergonomics change: aggregate(func="count") no longer needs a value_field
and accepts any declared field type, so it is the released, min-N-gated count.
count_objects is unchanged but now documented as not min-N-gated.
Changed¶
aggregate/aggregate_by/ MCPaggregate_objects:func="count"now accepts any declared field type (no numeric coercion) and may omitvalue_fieldto count every visible row under min-N; every other func with novalue_fieldrefuses withINVALID_PARAMS.NON_NUMERIC_AGGREGATEnow names the requested function. Resolves ontary#25.
Documentation¶
count_objects(MCP tool description,docs/api-reference.md/.ja.md,docs/mcp-serving.md) now states it is a visible-row count that is not min-N-gated, and points toaggregate_objects(func="count")as the released alternative.
[0.13.0] — 2026-09-13¶
uv add "ontary @ git+https://github.com/ryoochi0112/ontary@v0.13.0"
Three new docs pages (getting-started, CLI reference, testing; EN and JA) and one
tightening: a declared datetime must carry a time component, so date-only
strings that 0.12.0 accepted on write are now refused.
Documentation¶
- Three new docs pages, English and Japanese: a getting-started tutorial
(
docs/getting-started.md), the CLI reference (docs/cli.md:ontary validate,ontary serve --dev,ontary version,ontary-mcp, exit codes), and testing an ontology withontary.testing(docs/testing.md). The tutorial's whole program and the testing page's test functions are executed bytests/test_docs.py. - README gains a "Command line" section;
docs/mcp-serving.mdshows theontary serve --devone-liner next to the Python builder. docs/storage.mdstates which role applies the PostgreSQL RLS policies: the one that constructs the store on an empty database, which needsCREATEon the schema and becomes the table owner. Roles that connect later need only table DML.
Changed¶
- A declared
datetimevalue must carry a time component. A date-only string such as"2026-02-15"parsed as a naive midnight, so it was accepted on write and, as awherecomparison operand, silently never matched an offset-aware column. It is nowINVALID_RECORDon write andOPERATOR_TYPE_MISMATCHas an operand. Naive and offset-aware datetimes are both still accepted.
[0.12.0] — 2026-09-08¶
uv add "ontary @ git+https://github.com/ryoochi0112/ontary@v0.12.0"
mcp 2.x, hardened CI, and the first two SDK gaps picked from real use: ranges
in where and datetime comparisons. The mcp bump is breaking; pin
ontary<0.12 to stay on mcp 1.x.
Breaking¶
ontary[mcp]now requiresmcp>=2.1.1,<3(was>=1.27.2,<2); mcp 1.x is no longer supported — on 1.x the builders raiseImportErrornaming the range to install.- Both
build_mcp_serverandbuild_multi_consumer_mcp_serverreturnmcp.server.mcpserver.MCPServer(wasmcp.server.fastmcp.FastMCP). build_multi_consumer_mcp_serverno longer forcesstateless_http=True; pass transport options (stateless_http,json_response,transport_security,host;portonrun()) torun()/streamable_http_app(). On mcp 2.x each request resolves its own token in stateful sessions too.- The twelve tool handlers are
async def(2.x runs sync handlers on a worker thread, which the thread-affine SQLite store cannot serve); aConsumerResolverstays a synchronous callable and now runs on the event loop. - To stay on mcp 1.x, pin
ontary<0.12.
Added¶
whereconditions accept several operators on one field, AND-ed:{"decided_at": {"gte": a, "lt": b}}is a range. Before, a mapping with more than one key was refused asUNKNOWN_OPERATOR, so a half-open range over one property could not be written. A multi-key mapping stays learning-shaped for the hidden scope-key exemption, even when it contains anin.gt/gte/lt/lteon declareddatetimeproperties, compared as instants across UTC offsets. Before, comparisons were limited toint,float, anddate, so an ISO datetime column could only be matched exactly.docs/storage.mddocuments the drop-and-recreate path across a schema stamp (9 underontos→ 10 underontary), since neither backend has a migration ladder.- CI tests on Python 3.13 as well as 3.12, matching the classifiers.
codeql.yml: CodeQL for Python on every PR, onmain, and weekly.- A
workflowsjob inverify.ymllints the workflows with zizmor, so an unpinned action or a checkout that keeps its credentials fails the PR. SECURITY.md: supported versions, private vulnerability reporting, and what counts as a vulnerability in the engine.- Dependabot (
.github/dependabot.yml) foruvand GitHub Actions, weekly, grouped, with a 7-day cooldown, never automerged.
Changed¶
- Every workflow action is pinned to a commit SHA; every workflow declares
permissions: contents: readat the top, a timeout on every job, andpersist-credentials: falseon checkout.verifycancels a superseded run on a PR branch only. - The release gate no longer uses the uv cache: the job builds the bytes that are published, and a cache shared with PR runs is a poisoning surface.
renovate.jsonremoved. The Renovate app had never been installed on the repository, so the config was inert; Dependabot replaces it.- The ruff rule set is declared explicitly (
select = ["E4", "E7", "E9", "F", "I", "B", "C901"]) instead of extending the implicit default. ruff 0.16.0 widened the defaults from 59 to 413 rules; the explicit list keeps the pre-0.16 gate. - Dependabot's
mcpmajor-bump ignore was added for PR #10 and removed again by this change now that the migration has landed.
[0.11.0] — 2026-09-06¶
uv add "ontary @ git+https://github.com/ryoochi0112/ontary@v0.11.0"
OSS v0: ontary is cut to its core and published with a docs site. Every
removed public name is listed under ### Removed; this is the first release
that ships without them. Pre-v1 hygiene from the same cycle is under
### Changed.
Added¶
- Docs site at https://ryoochi0112.github.io/ontary/ (English / 日本語): MkDocs
Material +
mkdocs-static-i18n, built strict on every PR and deployed frommainby.github/workflows/docs.yml.make docsserves a local preview. - The stranger test in CI: the wheel is installed with
[mcp]into an empty venv, then the README quickstart and the tickets example run from it (scripts/stranger_smoke.py). docs/releasing.mdis back as a minimal runbook for the tag-driven PyPI publish; it replaces the v1-gate version listed under Removed.- The README quickstart now declares two object types and one action, opens a SQLite store, and builds an MCP server.
Removed¶
- The v1 acceptance gate, upgrade-fixture ladder, and
upgrade-fixture-honestyCI job (docs/v1-gate.md, the v1-gate docs/releasing.md, tests/fixtures/upgrade). - The storage envelope:
STORAGE_ENVELOPE_EXCEEDEDfinding,Ontology.diagnose(store=),ontary validate --store, scripts/scan_curve.py. - Error code
STORE_SCHEMA_INCOMPATIBLE. - ontary.explain (DecisionTrace, explain_read, explain_list, explain_scan) and the
ontary explainCLI. - ontary.erase, Store.erase_object_content, Store.object_erasure_state, EraseResult, the
ontary eraseCLI, and codes OBJECT_ERASURE_NOT_FOUND, OBJECT_ALREADY_ERASED. - Effects and the durable outbox: EffectDispatcher, EffectHandle, EffectMeta, EffectPayload, OutboxRecord, RetryPolicy, DrainReport, Ontology.effect, Ontology.action(effects=), ActionContext.emit, OntologyRuntime.drain_effects, OntologyClient.drain_effects, OntologyClient.outbox, ontary.testing.capture_effects, AuditEntry.effects, the effect_outbox table, and codes EFFECT_NOT_DISPATCHABLE, UNDECLARED_EFFECT, EFFECT_NOT_SERIALIZABLE.
- Ontology fingerprints and drift detection (ontary.fingerprint, Store.read/write_ontology_fingerprint, accept_ontology_drift=, code ONTOLOGY_DRIFT); declared type versions and upcasters (Ontology.object(version=), Ontology.upcaster, ontary.upcast, code UPCAST_FAILED); ontary.migrate (migrate_object_type, upcast_object_type).
- Connectors: ontary.connect (BaseConnector, CanonicalBatch, CanonicalRecord, MappingSpec, ObjectBinding, LinkBinding, RawTables, oid, run_pipeline), the dlt and bq extras, docs/connectors.md, and codes ENTITY_KEY_MISMATCH, MISSING_MAPPED_FIELD. Bulk loading stays via OntologyClient.ingest / ingest_links.
- docs/authority.md, docs/queries.md, docs/cookbook.md (content folded into docs/api-reference.md and examples/tickets/README.md); the Compatibility project URL.
Changed¶
- docs/compatibility.md is now a one-paragraph pre-1.0 policy; CHANGELOG's Removed/Changed sections are the migration guide.
SCHEMA_VERSIONis bumped 9 → 10, and stores are now create-or-refuse: both the SQLite and the Postgres backend refuse a store stamped at any other version withSTORE_VERSION_UNSUPPORTED, and neither migrates in place. A store created by 0.10.0 or earlier (stamped 9) must be recreated and reloaded from its source — for example viaOntologyClient.ingest. The internal mixinontary.store.migration.SqliteSchemaMigratoris renamedSqliteSchemaGateto match: it gates, it does not migrate.- Decision B: exact-scope-id match is the v1 contract; parent-covers-child
coverage is opt-in and post-1.0. The
covers_scopeandScopePolicydocstrings cite the decision instead of calling it deferred, and a contract test pins that a parent-scoped consumer never covers a child-owned row. - Every GitHub Actions
uses:entry inverify.ymlandrelease.ymlmoved off the Node 20 runtime:checkoutv7,setup-uvv10.0.1,upload-artifactv7,download-artifactv8. release.ymlserializes runs per tag (concurrency,cancel-in-progress: false) so a re-push cannot cancel a publish that is already uploading.
[0.10.0] — 2026-09-04¶
uv add "ontary @ git+https://github.com/ryoochi0112/ontary@v0.10.0"
Changed¶
- No change to the published storage envelope this crossing: a governed
aggregateor narrow-scope read still stays under one second below ~32,000 rows onObjectStore(SQLite) and ~1,600 rows onPostgresStore(PostgreSQL) — the same figures[0.9.0]published below. - An action handler's
ctx.insert(obj_type, payload)now fills a missing primary key from the runtime's configuredid_factorybefore callingstore.insert, instead of falling through to the store's ownuuid.uuid4()— the same seam invocation and effect ids already come from. A directstore.insertcall (bypassing anActionContext) is unaffected and keeps mintinguuid.uuid4(). See docs/compatibility.md § Auto-minted ids come fromid_factory(0.9 → 0.10). docs/storage.md#storage-envelopenow states its publication rule outright and adds an observed-run log, closing a silent SELECTION the doc never disclosed: a CI run of the Postgres storage-envelope curve step earns a#### Run <id>table only when it sets or lowers the published floor, and every run actually read — not only the ones that earn a table — is listed by id and its own worst per-row rate regardless. Two runs (33704034596,33705109711) had been read and silently dropped from the doc, with nothing anywhere in the tree recording that they ever ran; both are now visible in the observed log, and neither movesSTORAGE_ENVELOPE["PostgresStore"]— 1,600 rows at 0.000609s/row, unchanged.OBSERVED_POSTGRES_RUN_IDS(tests/test_docs.py) pins this log the same structural way the existingPUBLISHED_POSTGRES_RUN_IDSalready pins the four run tables, and is required to be a superset of it.- The memo-speedup table under
docs/storage.md#storage-envelope—2.45x/2.58xonObjectStore,3.02x/3.10xonPostgresStore— was an unattributed pair of percentages; it now names its source. ThePostgresStorepair is run 1's (33623316425) own n=25,000 measurement, already published in full above it in the same section, not a separate benchmark run; theObjectStorepair is labelled for what it always was, a single local laptop sample, never CI-produced. Neither number changed — this is a provenance correction, not a re-measurement. (The## [0.9.0]entry below quoting "2.45x-3.10x faster ... on both backends" is unaffected: those are the same two figures, and they check out against this now fully-attributed source.) - The
## [0.9.0]entry below quotes "cutting the store calls a governedaggregateor narrow-scope read issues from 7 to ~2 per row" as one combined figure, with no measurement basis stated for either read class next to it.docs/storage.md#storage-envelope's own supporting detail only shows theaggregateside (profilingaggregate()againstPostgresStoreat 8,000 rows: 16,006 calls, ~2 per row, down from 7) and never states a narrow-scope figure at all, so the[0.9.0]sentence silently generalized from one measured class to both without saying so. Both classes were in fact counted directly, onObjectStore(SQLite) at 2,000 rows:aggregate7.00 → 2.00 store calls per row, narrow-scope list 7.00 → 2.00 store calls per row — a single local count, never CI-produced, the same evidentiary class as this project's otherObjectStore/SQLite figures. The[0.9.0]claim itself is unchanged and was not wrong, only unattributed for narrow-scope; this is a provenance correction, not a re-measurement.
[0.9.0] — 2026-09-03¶
The storage-envelope release: decision C (how large may one object type get?) is resolved with a measured, published number and an advisory runtime signal, never an enforced ceiling. No path that succeeded at 0.8.0 fails here, and every existing read answers exactly what it answered before — most of them faster.
uv add "ontary @ git+https://github.com/ryoochi0112/ontary@v0.9.0"
Added¶
docs/storage.md#storage-envelopepublishes a measured, reproducible envelope for both shipped backends: a governedaggregateor narrow-scope read stays under one second below ~32,000 rows onObjectStore(SQLite) and ~1,600 rows onPostgresStore(PostgreSQL — a conservative floor measured across four CIpostgres:16runs: a GitHub-hosted runner is a shared VM, not fixed hardware, so the doc publishes all four runs' dispersion rather than a single point estimate). A bounded, unordered page read is flat at either size and carries no measured ceiling. The Postgres curve is loopback-measured; the doc publishes the correction arithmetic (roughly+ 2 x RTT x rowspost-memo, down from+ 7 x RTT x rowspre-memo) an operator applies for their own network's round-trip time.ontology.diagnose(store=...)gains one advisory rule: aFinding(codeSTORAGE_ENVELOPE_EXCEEDED,severity="warn") fires per object type whose stored row count, counted per tenant and never as a cross-tenant total, crosses its backend's published envelope. Passing nostore, or a store under the envelope, returns no such finding. Nothing refuses, and this adds no error code.OntologyRuntime.explain_scan(consumer, obj_type, where=None)returns aScanReport(rows_scanned,rows_returned,rows_hidden_by_scope) for one governed read. It followsDecisionTrace: reachable only fromOntologyRuntime, absent from consumer-bound clients,OntologyClient, and MCP.- A call-scoped memo (
ontary.scope._ScopeReadCache) now reads a shared parent row at most once per guarded read instead of once per scanned row, cutting the store calls a governedaggregateor narrow-scope read issues from 7 to ~2 per row. Measured 2.45x-3.10x faster at 25,000 rows across both linear read classes on both backends — comfortably past the >= 2x this decision required. It is proven result-identical, including where aScopePolicy.row_visibilitypredicate or aCustomResolverparticipates in the visibility decision, and its read-once-per-guarded-read behavior is documented as an implementation detail of the current engine, not an API guarantee (docs/storage.md#read-consistency). exists()now stops at the first visible row instead of walking the full selection — measurably cheaper thancount()when an early match exists, with an identical answer on every existing case (empty selection, no match, min-N gated, scope-hidden).
Changed¶
Store's protocol docstring no longer invites a third-party backend. It used to say any other backend "can too [satisfy the protocol] by simply matching these method signatures"; a 2026-09-01 human ruling recorded this seam as engine-internal — only the three shipped backends (ObjectStore,InMemoryStore,PostgresStore) are supported — and the docstring is softened to say so.
This is Changed, not Breaking, checked against every clause
docs/compatibility.md lists:
Store is neither removed nor renamed in ontary.__all__ (see the residue below) and
no method signature changed, so the "obvious" clause is not met; no Declarations
string changed (1); no error code changed meaning or disappeared (2); no
EffectMeta/AuditEntry/EffectRecord/OutboxRecord field was removed or re-meant
(3); no refusal became permissive or a permissive path started refusing — every one
of the three backends behaves exactly as it did at 0.8.0 (4); SCHEMA_VERSION is
unchanged at 9 (5); and no descriptor-IR/fingerprint-affecting change was made (6). No
clause is met.
The residue, stated plainly because this sentence was the only place the extension
point was ever promised: Store REMAINS in ontary.__all__ — removing it would
itself be breaking, and the type is needed in annotations. A reader still meets the
name at the front door; only the promise that a class of their own could satisfy it
as a supported backend is withdrawn.
Migration notes¶
- None. No path that worked at 0.8.0 stops working and no call site needs to change;
examples/tickets/README.md's own migration diary records that the storage-envelope work forced no change to the reference app.
[0.8.0] — 2026-08-26¶
The removal-and-queries release: remodeling and removal become governed business operations, and the query surface becomes shape-complete. This is a pre-1.0 minor bump; read the migration notes before upgrading.
uv add "ontary @ git+https://github.com/ryoochi0112/ontary@v0.8.0"
Added¶
ActionContext.retire(obj_type, obj_id)and.unlink(link_api_name, from_id, to_id)are the only handler-facing SDK removal spellings. They run only inside a handler transaction; retirement cascade-closes every live link that references the object on the side its link type declares for that object type, in that same transaction, and the object and link closures are captured inAuditEntry.writes.Store.read_last(obj_type, obj_id)returns an object's newest row whether or not it is still live, andStore.links_from_asof/links_to_asof(link_type, id, asof)return the links that were live at a named instant — links closed at or after it included. All three on all three shipped backends.read_currentanswersNoneboth for an id that never existed and for a retired one, andlinks_from/links_toanswer[]for an object whose links a retirement cascade closed; the action target scope gate needs both distinctions. Additive — a custom backend must implement all three.- All three
Storebackends now provideretire_object,close_link, anderase_object_content. Retirement closes the current object row, link closure closes one live relationship, and erasure purges content while leaving structural tombstone rows intact. - Source-removal authority is enforced for governed actions: an object type must
declare whole-type
owned=Trueand a link type must declareowned=True. An undeclared source-backed removal raisesUNDECLARED_SOURCE_REMOVAL; a cascade that reaches such a link rolls back the whole action transaction. where=now supports the typed operator setgt/gte/lt/lte/in/ne/containson every read surface, with catalogued unknown-operator and operator-type refusals.- Consumer object reads accept
order_byon declared payload fields and are bounded by default withDEFAULT_READ_LIMIT=1000; passlimit=Noneexplicitly for an unbounded list. Inside a declared Function,BoundQuery.listremains unbounded by default and returns a bare list; passlimit=when aPageis wanted. - The design guide's CRUD lint now rejects
Remove*action names alongsideDelete*, keeping removal as a domain business verb. ontary.erase.erase_object()gives store-holding operators an audited, idempotent one-object erasure API. It remains absent from the authoring root,OntologyClient,ActionContext, and MCP surfaces.ontary eraseprovides the store-file CLI path for that operator erasure API;--operatorrecords the supplied identity (or the OS user when omitted), successful erasures and coded no-op reports exit zero, and coded refusals exit non-zero.GuardedQuery,OntologyClient, andBoundQuerynow expose visible-rowcount()andexists()reads, and MCP exposes the same guarded count throughcount_objects.aggregate()andaggregate_by()now acceptfunc="mean"|"count"|"sum"| "min"|"max"on guarded, client, Function, and MCP read surfaces. Every reduction retains the existing per-selection/per-group min-N release gate, and MCP gains anaggregate_objectstool (twelve tools total).traverse(..., reverse=True)now walks a link from its target side acrossGuardedQuery,OntologyClient,BoundQuery, typedLinkHandlecalls, and MCP. The chosen spelling keeps one traversal method and makes direction an explicit call-site choice; human denial foridentity_revealinglinks is symmetric and occurs before either direction resolves rows.
Changed¶
- Action target scope gate after retirement: the
scope_semantics="target"gate is now skipped only for a target that was NEVER stored. It used to be skipped for anything the store had no live row for, which after this release's retirement verb includes a RETIRED object — so a consumer outside that object's scope reached the handler with no scope check, andctx.create_link(which validates no endpoint) let its writes through.
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.
Outside the resolved scope, SCOPE_DENIED; inside it, the handler's own
precondition refusal as before, including OBJECT_ALREADY_RETIRED. It is a
security fix, not a regression.
Point-in-time, rather than simply "retired rows allowed", because the two
obvious readings fail in opposite directions and both are authorization
defects. Resolving ancestors from the newest row unconditionally lets an
ancestor retired long ago beat 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 operator who does own it, permanently, since a disbanded
team cannot be un-retired. One instant answers both: at the target's
valid_to the ancestor retired earlier was already closed, and the one that
outlived the target was not.
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 and erasure at its OWN hop:
SelfScopeanswers with the object's own id, which neither retirement nor erasure takes away.DirectPropertyreads the scope key off the payload, anderase_object_contentblanks by design what that rule reads, which no history-aware read can recover. Retirement leaves the payload alone, because this gate reads the newest row rather than the live one, so erasure is the only lifecycle event this hop's OWN READ loses to. That is the target itself when the target isDirectProperty-scoped; it is equally an ANCESTOR whose own hop isDirectProperty, which denies aViaLink-scoped target whose link erasure preserved. Erasure destroying the scope key is the point of erasure, not a gap in the gate.ViaLinkclimbs thelinkstable, which erasure preserves, so erasure costs this hop nothing. 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 or erased 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.
This history-aware resolution is scoped to that one gate. resolve_owning_scope
takes include_retired=False by default and every consumer read keeps it:
resolving a retired — or erased — row's scope would put its children back in
a reader's visible set, carrying a population past min_n and releasing an
aggregate computed over the erased subject's own rows.
- Erasure follows a link endpoint no declared object can own: a recorded
link write whose endpoint id matches the object being erased is now treated
as a reference unless an object of the endpoint's DECLARED type really
carries that id. create_link validates neither endpoint existence nor
endpoint type, so the declaration alone was letting erasure walk past audit
and outbox rows that named the erased object. An endpoint an existing
same-id object of the declared type can own is still left alone.
It is a security fix, not a regression.
- Page and TypedPage now refuse len(page), page[i], and page[i:j]
with PAGE_NOT_ITERABLE, the refusal the code already documented for them
and only implemented for iteration. Both previously raised a bare
TypeError naming neither the page nor .items. Truthiness is unchanged.
- Ontology.diagnose() now reports the empty contributor_rules rule list
that ScopePolicy.validate refuses at startup, so the sweep no longer
returns clean for a declaration that cannot be built.
- Hidden-field aggregate disclosure guard: on a consumer surface,
aggregate(func="mean"|"count", value_field=<hidden field>)now raisesVISIBILITY_DENIED; the hidden-field exemption requires an author-declared Function over a type declaringcontributor_rules. This closes the confirmed mean/count plus complementary-selection oracle that recovered an individual hidden value. It is a security fix, not a regression. - Scope-key disclosure guard: on a hidden
DirectPropertyscope-routing key, only bare equality andinover an explicit list remain exempt inwhere.gt/gte/lt/lte/ne/contains,inover a non-list,order_by, andgroup_bynow refuse withVISIBILITY_DENIED. This closes the confirmedcontainsalphabet-walk oracle (and prevents rank/group-key disclosure). It is a security fix, not a regression. - min-N contributor counting: the floor is now measured over the rows that
carry
value_field, and rows whose contributor does not resolve count as one unknown identity between them rather than one apiece. A type mapped to an emptycontributor_ruleslist no longer opens the hidden-field exemption, andScopePolicy.validaterefuses that declaration. This closes the confirmed disclosure where retiring one contributor — or closing its links — made several rows by that one person look like several people and released their mean, and the one where a group ofmin_npeople in which only one answered released that person's answer as the "mean". It is a security fix, not a regression. wheresnapshot and supplied-value rule: eachwheremapping and each condition inside it is now read exactly once, at the public boundary, and every gate and the row matcher consume that one snapshot — closing the confirmed split where a mapping answering differently on a second read was classified as supply-shaped equality and executed as acontainsalphabet walk. The supply-shaped exemption now also requires an operand that actually supplies a value (str/int/float/bool/date), sowhere={<hidden key>: None},{"in": [None]}, and{"in": []}raiseVISIBILITY_DENIEDinstead of acting as a free per-row null probe. It is a security fix, not a regression.
Migration notes¶
- Mapping-valued
whereoperands:wheremappings are now operator syntax, so dict equality on a declaredjsonproperty must use theinescape, for examplewhere={"data": {"in": [{"kind": "a"}]}}. - Object reads are bounded by default on consumer surfaces: omitting
limitfromGuardedQuery.get_objectsorOntologyClient.listnow returns aPagebounded byDEFAULT_READ_LIMIT=1000. Inside a declared Function,BoundQuery.listis deliberately unbounded by default and returns a bare list; passlimit=to opt into aPage. Passlimit=Noneexplicitly for the unbounded list form on consumer surfaces; update callers that previously relied on bare consumer reads returning a list. - 0.8.0 grouped-empty aggregate refusal:
aggregate_by()over an empty visible selection now raisesMIN_N_VIOLATION, matchingaggregate(), instead of returning{}. Callers that treated{}as an empty-result sentinel must catch the catalogued visibility refusal instead. -
BoundQuery.traverselegacyvia=spelling removed in 0.8.0:BoundQuery.traverse(from_id, via=link_handle)is removed; useBoundQuery.traverse(link_handle, from_id). Passingvia=now raises a natural PythonTypeErrorsignature error, not a catalogued refusal. Function bodies passingvia=must be updated; mypy will flag typed call sites, but untyped/Anyones only fail at runtime. -
IngestErrornow subclassesOntaryError: a handler orderedexcept OntaryError:beforeexcept IngestError:now shadows the ingest branch. Code that relied onIngestErrorbeing outside the hierarchy must reorder its handlers. Conversely,except OntaryErrornow catches client-level ingest failures that previously escaped. -
Aggregates near the min-N floor may now refuse: three counting changes that only ever refuse more. An aggregate over an optional
value_fieldcounts only the rows carrying it; unresolved contributors count as one unknown identity collectively; andScopePolicy.validaterejects a type mapped to an emptycontributor_ruleslist — remove the entry to opt out of contributor de-duplication. Selections that sat at the edge ofmin_nwill start raisingMIN_N_VIOLATION. That number was never backed bymin_ndistinct people. -
Null
whereoperands on a hidden field now refuse: a bareNone, aninover a list containingNone, and an emptyinlist no longer receive the scope-key exemption and raiseVISIBILITY_DENIED. Null filtering on any readable field is unchanged. -
Function audit scope:
Declarations.audit_scopenow records that actions are always audited, Functions are audited when they declare capabilities unless overridden per Function, and a call that releases a hidden field through the contributor exemption is audited even when it declaresaudit=False. The latter appends onekind="function"entry for each call that actually releases. -
New error codes: lifecycle and query refusals add
OBJECT_ERASURE_NOT_FOUND,OBJECT_RETIRE_NOT_FOUND,OBJECT_ALREADY_ERASED,OBJECT_ALREADY_RETIRED,LINK_NOT_FOUND,STALE_CURSOR,UNDECLARED_SOURCE_REMOVAL,UNKNOWN_OPERATOR,OPERATOR_TYPE_MISMATCH,PAGE_NOT_ITERABLE, andGROUP_KEY_COLLISION. Branch on these stable codes where a refusal needs different handling.
[0.7.0] — 2026-08-25¶
The DX-suite release: a stricter authoring front door, fail-loud ingest and query validation, bounded MCP reads, operator diagnostics, and runnable authoring/serving helpers. This is a pre-1.0 minor bump; read the migration notes before upgrading.
uv add "ontary @ git+https://github.com/ryoochi0112/ontary@v0.7.0"
Migration notes from 0.6.0¶
-
Front door exports (additive):
ontary.__all__now includesOntologyClient,InMemoryStore,EffectMeta,EffectDispatcher,ScopePolicy,CustomResolver,ref,scope_ref,RetryPolicy,DrainReport,OutboxRecord, andDeclarations. Prefer the replacement root imports, for examplefrom ontary import OntologyClient; canonical submodule imports such asfrom ontary.client import OntologyClientremain supported. -
Ingest now raises by default:
client.ingest()andclient.ingest_links()now default toon_error="raise". Catchontary.ingest.IngestErrorand inspect itsreport;len(error.report.inserted_ids)is the committed-record count. Passon_error="report"to retain the old report-returning behavior. The committed prefix is still written before a later record fails. -
IngestErroris an exception, not a Pydantic model: it now subclassesExceptionand carriesreportplus the committed-record information; replaceisinstance(error, BaseModel)withisinstance(error, IngestError)orexcept IngestError. Callers usingmodel_fieldsshould inspect the explicit exception fields (index,reason,code, andreport) instead.model_dump()remains only a limited compatibility serializer for the old error-row fields; useerror.report.model_dump()for the complete batch report. -
Execute signature and result shape: use
client.execute(params)orclient.execute(params=params)for a typed action, andclient.execute("ActionName", {"field": value})(or the equivalentaction=...,params=...keywords) for the string form. Replace callers that depended on the old positional-only parameter names. Action handlers may now return nested JSON-safedict[str, Any]; callers that assumed every result value was a string should consume the JSON value tree instead. -
Malformed
execute/traversecalls now refuse asINVALID_PARAMS: callingexecuteortraversewith a shape no overload accepts (for example the string form without params orfrom_id) now raisesValidationFailedwithcode="INVALID_PARAMS"instead of anInternalErrorwithcode="INTERNAL_ERROR";INTERNAL_ERRORis reserved for genuine engine invariants. -
String traversal positional order: the string form changed from
client.traverse(obj_type, obj_id, link)toclient.traverse(obj_type, link, from_id). All three arguments arestr, so mypy cannot detect this migration; update positional call sites manually. The replacement typed form isclient.traverse(link_handle, from_id). -
BoundQuery.traverselegacyvia=spelling removed in 0.8.0:BoundQuery.traverse(from_id, via=link_handle)deliberately remains supported, and stringBoundQuery.traverse(link, from_id)remains unchanged. New code may use the handle-firstBoundQuery.traverse(link_handle, from_id)form; treat thevia=spelling as legacy if planning a future breaking cleanup. This legacy spelling was removed in 0.8.0; see the 0.8.0 migration note above. -
Unknown
wherekeys now refuse: typed, string, aggregate, and MCP read surfaces validatewhereagainst declared payload fields. A typo now raisesValidationFailedwithcode="UNKNOWN_FIELD"instead of returning[]. Replace typo-tolerant empty-result handling with a declared field name and catch/branch onUNKNOWN_FIELD; lineage fields remain non-filterable. -
Constructor and enforcement hardening:
ScopePolicyrejectsmin_n < 1, emptylevels, and duplicate levels at construction, so an ontology declaration withscope_levels=[]is rejected when its policy is materialized instead of producing a usable definition. Use a non-empty, unique level list andmin_n >= 1. Registry getters now raiseValidationFailedinstead of bareKeyError: handleUNKNOWN_OBJECT_TYPE,UNKNOWN_LINK_TYPE,UNKNOWN_ACTION, orUNKNOWN_NAMEas appropriate. Enforcement-path assertion failures are now real coded validation/errors, so replaceAssertionErrorcatches with the documentedValidationFailed/kind classes. Unknown-link traversal on the client and MCP surfaces intentionally remainsUNKNOWN_NAME. -
MCP consumer contract:
query_objectsnow defaults to a 100-row page, caps pages at 1000, and returnsnext_cursor; passlimitand the returned cursor asafterto paginate. Thewheregrammar is equality-only on payload fields. Tool parameters are validated inside the structured error envelope, and all successful results are JSON-normalized; return JSON-safe values and handle envelope errors bycode/kind, not raw framework text. Grouped-aggregateMIN_N_VIOLATIONmessages now saycount withheld; codes are unchanged, and message text is not a wire contract. Read tools carryreadOnlyHint, whileexecute_actioncarriesdestructiveHintthroughToolAnnotations. The MCP extra is now pinned tomcp>=1.27.2,<2. -
New connector declaration requirement: concrete
BaseConnectorandSourceConnectorsubclasses must define a class-levelname; missing it now fails at class definition. Addname = "your-source-system"to the subclass (abstract connector bases may still omit it). -
Fingerprint behavior for new property types:
dateandchoicesare fingerprint-neutral for ontologies that do not use them, so upgrading the SDK alone leaves those digests byte-identical. An ontology that declareschoices=[...]moves its digest; declaring adateproperty likewise changes that declaration's shape. Follow the fingerprint acceptance procedure, including row migration before accepting a new choices constraint.
Additions¶
Ontology.diagnose()andFinding, including design-guide lint findings.- Runtime-only explain traces via
explain_read()andexplain_list(). - Deterministic test helpers in
ontary.testing. - CLI
validate,explain,version, and localhost-onlyserve --dev. - Five runnable recipes in the cookbook.
- Runnable MCP and effects-drain examples under
examples/tickets/.
[0.6.0] — 2026-08-22¶
The release that completes the SDK-simplification work. This is a pre-1.0 minor
bump, so the public Python surface and the MCP error.type field have breaking
changes even though the store schema does not.
uv add "ontary @ git+https://github.com/ryoochi0112/ontary@v0.6.0"
Migration from 0.5.0¶
0.5.0 was never tagged. Its Lineage → SourceLineage migration is part of
the pre-0.6.0 history archived in github.com/ryoochi0112/ontic. A consumer coming
from the last published tag, v0.4.0, must apply that migration before the 0.6.0
changes. This note does not create or move a git tag.
-
BoundQuery reads: replace
BoundQuery.get_object(obj_type, obj_id)withBoundQuery.get(obj_type, obj_id), and replaceBoundQuery.get_objects(obj_type, where, ...)withBoundQuery.list(obj_type, where, ...). There are no compatibility shims. The aggregate methods also gained typed overloads; that is additive and non-breaking. -
codeis now required when constructing a kind class. The class-level default is gone:ValidationFailed("msg")raisesTypeError, andValidationFailed("msg", code="INVALID_PARAMS")is the replacement. Every code and its cataloguedkindare unchanged — this changes only how an exception is constructed, never what travels on the wire. Passing an emptycoderaisesValueError.
If you construct these exceptions yourself, name the code you mean at each
site; the previous class defaults were INVALID_PARAMS (ValidationFailed),
PRECONDITION_FAILED (PreconditionFailed, ActionError),
PERMISSION_DENIED, VISIBILITY_DENIED, AUTHORITY_ERROR,
CARDINALITY_VIOLATION (ConflictError) and INTERNAL_ERROR
(InternalError), so passing those preserves today's behaviour exactly. If
you only catch these exceptions, nothing changes.
Why: a default meant that any construction reached through a rebound name —
an alias, an assignment, a function parameter, a for target, a getattr —
silently shipped the default code instead of the intended one, and the
consumer branching on .code saw the wrong stable value. Five review rounds
showed that no source-level guard closes this in a language where any name
can be rebound: each round modelled one more binding shape and the next found
another. Requiring the argument makes the wrong construction unwriteable
rather than undetectable, and retired two guards that existed only to chase it.
-
Exception collapse: the legacy exception names and the
CodedErroralias are deleted, not kept as aliases. Catch the kind class shown below and branch on its unchangedcodewhen the specific refusal matters.ActionErrorremains the handler-facing precondition vocabulary. -
VisibilityError:MinNViolation→ (VisibilityError,MIN_N_VIOLATION);VisibilityDenied→ (VisibilityError,VISIBILITY_DENIED). PermissionDenied:ActionPermissionError→ (PermissionDenied,PERMISSION_DENIED; alsoSCOPE_DENIED);Unauthenticated→ (PermissionDenied,UNAUTHENTICATED);ConsumerUnresolved→ (PermissionDenied,CONSUMER_UNRESOLVED).PreconditionFailed:CapabilityNotProvided→ (PreconditionFailed,CAPABILITY_NOT_PROVIDED);EffectNotDispatchable→ (PreconditionFailed,EFFECT_NOT_DISPATCHABLE);FunctionError→ (PreconditionFailed,FUNCTION_ERROR).ValidationFailed:EntityKeyMismatchError→ (ValidationFailed,ENTITY_KEY_MISMATCH);UndeclaredCapability→ (ValidationFailed,UNDECLARED_CAPABILITY);UndeclaredEffect→ (ValidationFailed,UNDECLARED_EFFECT);EffectNotSerializable→ (ValidationFailed,EFFECT_NOT_SERIALIZABLE);UnknownName→ (ValidationFailed,UNKNOWN_NAME);UnknownField→ (ValidationFailed,UNKNOWN_FIELD);OntologyValidationError→ (ValidationFailed,ONTOLOGY_INVALID);HydrationError→ (ValidationFailed,INVALID_RECORD);InvalidLimit→ (ValidationFailed,INVALID_LIMIT);AfterWithoutLimit→ (ValidationFailed,AFTER_WITHOUT_LIMIT);InvalidGroupBy→ (ValidationFailed,INVALID_GROUP_BY);NonNumericAggregate→ (ValidationFailed,NON_NUMERIC_AGGREGATE);ScopePolicyError→ (ValidationFailed,SCOPE_POLICY_ERROR);UnknownObjectType→ (ValidationFailed,UNKNOWN_OBJECT_TYPE);UnknownLinkType→ (ValidationFailed,UNKNOWN_LINK_TYPE);ObjectNotFound→ (ValidationFailed,OBJECT_NOT_FOUND);InvalidBatch→ (ValidationFailed,INVALID_BATCH);InvalidCursor→ (ValidationFailed,INVALID_CURSOR); andDeclaredShapeViolation→ (ValidationFailed,INVALID_RECORD).AuthorityError: the removed store-specificontary.store.AuthorityError→ (AuthorityError,AUTHORITY_ERROR);SourceCreateRefused→ (AuthorityError,SOURCE_CREATE_REFUSED);UndeclaredSourceWrite→ (AuthorityError,UNDECLARED_SOURCE_WRITE). TheAuthorityErrorkind class itself is the replacement; it was not removed.ConflictError:StoreBusy→ (ConflictError,STORE_BUSY);CardinalityViolation→ (ConflictError,CARDINALITY_VIOLATION);StoreVersionUnsupported→ (ConflictError,STORE_VERSION_UNSUPPORTED);OntologyDrift→ (ConflictError,ONTOLOGY_DRIFT);StoreSchemaIncompatible→ (ConflictError,STORE_SCHEMA_INCOMPATIBLE);CallerTransactionRefused→ (ConflictError,CALLER_TRANSACTION_REFUSED); andUpcastFailed→ (ConflictError,UPCAST_FAILED).- The old root export
ontary.StoreErrorwas a base class, not an internal-error replacement. Its fallback codeSTORE_ERRORremains in the catalog, but there is no one-to-one kind-class replacement: a store catch-all must becomeexcept OntaryError:(fromontary.errors, or the rootontary.OntaryError; alternatively use an explicit tuple of the kind classes below).except InternalError:is NOT equivalent and will not catch the concrete store failures. The 13 direct subclasses in the oldStoreErrorhierarchy now land as follows:StoreBusy→ (ConflictError,STORE_BUSY);UnknownObjectType→ (ValidationFailed,UNKNOWN_OBJECT_TYPE);UnknownLinkType→ (ValidationFailed,UNKNOWN_LINK_TYPE);ObjectNotFound→ (ValidationFailed,OBJECT_NOT_FOUND);CardinalityViolation→ (ConflictError,CARDINALITY_VIOLATION);InvalidBatch→ (ValidationFailed,INVALID_BATCH);InvalidCursor→ (ValidationFailed,INVALID_CURSOR);StoreVersionUnsupported→ (ConflictError,STORE_VERSION_UNSUPPORTED);DeclaredShapeViolation→ (ValidationFailed,INVALID_RECORD);OntologyDrift→ (ConflictError,ONTOLOGY_DRIFT);StoreSchemaIncompatible→ (ConflictError,STORE_SCHEMA_INCOMPATIBLE); the old store-specificAuthorityError→ (AuthorityError,AUTHORITY_ERROR); andCallerTransactionRefused→ (ConflictError,CALLER_TRANSACTION_REFUSED). The inheritedSourceCreateRefusedandUndeclaredSourceWriteare bothAuthorityErrorfailures, with their specific codes shown above. CodedErrorwas an alias, not a distinct code: replace it with theOntaryErrorbase and inspect the concrete error'scodeandkind(the base default remainsINTERNAL_ERROR).
The authoritative migration rule is that every error code and kind value
is UNCHANGED; those values are the wire/compatibility surface. The names
to catch are now OntaryError plus VisibilityError, PermissionDenied,
PreconditionFailed, ValidationFailed, AuthorityError, ConflictError,
InternalError, and ActionError.
The following old dual-inheritance relationships are also gone. Catch the
kind class and code shown above instead: UnknownName was a
(ValidationFailed, KeyError) → (ValidationFailed, UNKNOWN_NAME),
UnknownField was a (ValidationFailed, KeyError) →
(ValidationFailed, UNKNOWN_FIELD), HydrationError was a
(ValidationFailed, ValueError) → (ValidationFailed, INVALID_RECORD),
and ActionPermissionError was a (PermissionDenied, ActionError,
PermissionError) → (PermissionDenied, PERMISSION_DENIED or
SCOPE_DENIED). At 0.6.0, an existing except KeyError: around a typed
read, except ValueError: around hydration, or except ActionError: (or
except PermissionError:) around client.execute(...) silently stops
catching these failures: there is no import or type error to expose the
break.
-
MCP wire change: in an MCP error payload,
error.typenow carries the kind-class name, such asVisibilityError,PermissionDenied, orValidationFailed, instead of the legacy exception-class name.error.codeanderror.kindremain byte-stable. This is a BREAKING change for consumers that branch onerror.type; branch oncodeorkindfor the stable contract. -
Deleted drift and identity features: replace
assess_driftby comparingOntologyFingerprintvalues directly, and replacedescribe_driftby comparing the fingerprints'.typesmappings.DriftAssessmenthas no replacement object: useOntologyFingerprintfor the comparison and handle theConflictErrorwith codeONTOLOGY_DRIFT(the formerOntologyDrift) for the store's refusal. The fingerprint check-and-refuse behavior is UNCHANGED; only the descriptive-assessment API was removed. The deletedresolve_identities,IdentityRule, andIdentityMatchAPIs have no SDK replacement: matching is caller-owned, and theontary.connect.identitymodule is gone. -
Front-door demotions:
ontary.__all__is exactly 45 names. T10 deleted nothing from the engine; names outside that curated front door remain importable from their canonical submodule. The complete inventory below is copied fromtests/test_docs.py'sDEMOTED_NAMES_BY_MODULE, which is the source of truth for both the docs and the guard. ImportMappingValidationErrorfromontary.connect.
| Destination module | Names still importable there |
|---|---|
ontary.actions |
ActionExecutor |
ontary.audit |
CapabilityAccessRecord, EffectRecord |
ontary.authoring |
ref, scope_ref |
ontary.client |
OntologyClient, OntologyRuntime |
ontary.connect |
LinkSkip, MappingValidationError, RunReport, SourceConnector, SourceLineage, map_batch, run_dlt_extract, to_date, to_datetime, to_optional_date, to_optional_datetime |
ontary.declarations |
Declarations |
ontary.effects |
EffectDispatcher, EffectMeta |
ontary.errors |
ERROR_CODES, ErrorCodeInfo, Kind |
ontary.fingerprint |
OntologyFingerprint, fingerprint_ontology |
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, PropertyType, ScopeLevel, Upcaster |
ontary.migrate |
MigrationFailure, MigrationReport, migrate_object_type, upcast_object_type |
ontary.ontology |
OntologyDef |
ontary.outbox |
DEFAULT_RETRY_POLICY, DrainReport, OutboxRecord, OutboxState, RetryPolicy |
ontary.query |
GuardedQuery |
ontary.scope |
CustomResolver, Direction, RowVisibilityFn, ScopePolicy, ScopeRule, resolve_contributor, resolve_owning_scope |
ontary.security |
ConsumerKind, covers_scope |
ontary.store |
AuditEntry, DEFAULT_BATCH, DEFAULT_TENANT, InMemoryStore, Lineage, SCHEMA_VERSION, StoredObject, WriteRecord, accept_ontology_fingerprint, check_ontology_fingerprint |
ontary.upcast |
upcast_payload |
- Docs: the README narrative is now split into Diátaxis-shaped English
pages:
docs/storage.md,docs/connectors.md,docs/mcp-serving.md,docs/effects.md,docs/queries.md, anddocs/authority.md. The README is a compact front door; the existing Japanese API-reference and ontology-design documents remain the maintained JA surfaces.
Changed¶
-
Store compatibility: the store schema was NOT bumped.
SCHEMA_VERSIONremains 9, and existing SQLite store files open with no migration. SQLite and Postgres now share one SQL/DDL source; that is an internal consolidation with no behavior change, and it is why the Postgresaudit_logcolumn order changed on FRESH-CREATE schemas only. -
The Postgres RLS session GUC is renamed
ontarydk.tenant→ontary.tenant, and RLS policies are persisted database state. A database created by ≤ 0.3.0 hastenant_isolationpolicies compiled against the old GUC;_init_schemasees the schema stamp already current (still v9) and will not recreate them. Because RLS here is fail-closed, 0.4.0 against such a database reads zero rows. Run this once per existing RLS-enabled database before upgrading:
ALTER POLICY tenant_isolation ON objects
USING (tenant = current_setting('ontary.tenant', true))
WITH CHECK (tenant = current_setting('ontary.tenant', true));
ALTER POLICY tenant_isolation ON links
USING (tenant = current_setting('ontary.tenant', true))
WITH CHECK (tenant = current_setting('ontary.tenant', true));
ALTER POLICY tenant_isolation ON audit_log
USING (tenant = current_setting('ontary.tenant', true))
WITH CHECK (tenant = current_setting('ontary.tenant', true));
ALTER POLICY tenant_isolation ON effect_outbox
USING (tenant = current_setting('ontary.tenant', true))
WITH CHECK (tenant = current_setting('ontary.tenant', true));
Pre-0.6.0 history and tags v0.1.0–v0.4.0 live in github.com/ryoochi0112/ontic.