Storage backends, tenancy, and schema¶
Back to the README · API reference
How-to guide — This page gives steps to choose and bind a storage backend, and the API reference lists the classes behind it.
The runtime depends on the Store protocol, not on a particular database. Choose
the backend for the deployment shape, then bind the ontology once and hand out
consumer views from that runtime. Storage does not replace the guarded client
surface: direct store access is trusted application code, while reads through an
OntologyClient or MCP server remain scope-, sensitivity-, and min-N-aware.
Choose a backend¶
| Backend | Best fit | Import |
|---|---|---|
InMemoryStore |
Fast tests and dependency-free doubles | ontary |
ObjectStore |
A local file, script, or single-process application | ontary |
PostgresStore |
A deployment with multiple application processes | ontary |
All three implement the same store contract. The action, ingest, and guarded-read layers therefore do not need backend-specific branches. A typical local runtime is:
from ontary import ObjectStore
store = ObjectStore(ontology.registry, path="ontary.sqlite")
runtime = ontology.bind(store)
For a test double, import the backend from the front door:
from ontary import InMemoryStore
store = InMemoryStore(ontology.registry)
PostgreSQL is selected by the optional postgres extra and keeps the same runtime
construction:
from ontary import PostgresStore
store = PostgresStore(
ontology.registry,
"postgresql://localhost/ontary",
tenant="acme",
)
runtime = ontology.bind(store)
The PostgreSQL implementation has a few intentional differences from SQLite:
- SQLite supports its historical migration path because local store files can predate the current engine. PostgreSQL creates the current schema and refuses a database whose stamp does not belong to this engine rather than silently adopting an unknown layout.
- Object payloads are stored as
TEXT, matching the other backends. The engine does not query inside payload JSON, so JSONB normalization would buy no runtime behavior while making byte-level parity harder: the dominant linear cost is Python-side scope resolution, not SQL scanning. - Row-level security can provide a database-side tenancy check in addition to the engine predicates. It is defense in depth, not a replacement for the store's tenant binding.
The PostgreSQL integration suite is optional locally. make verify remains an
offline gate; an absent PostgreSQL service is an expected skip, not a reason to
change the test configuration.
Tenancy is bound to the store¶
A store instance represents one tenant. Pass that tenant when constructing the store; there is no request-scoped tenant switch:
acme = PostgresStore(ontology.registry, dsn, tenant="acme")
globex = PostgresStore(ontology.registry, dsn, tenant="globex")
acme.insert("Widget", {"id": "w-1"}, source)
assert globex.read_current("Widget", "w-1") is None
The boundary covers objects, links, and audit entries. Primary keys are unique within a tenant rather than across the whole deployment, so two tenants may use the same domain identifier without colliding. The tenant is fixed at construction precisely because a per-call argument is easy to omit and a missed argument would have a cross-tenant failure direction.
On PostgreSQL, rls=True creates FORCE row-level-security policies keyed by the
current session tenant. An unset session setting matches nothing. A superuser can
bypass PostgreSQL RLS by database design, so application connections must use an
ordinary role. rls=False disables only this second database layer; the engine's
tenant predicates still run.
The RLS statements run once, in the same transaction that creates the schema, so
the role that constructs the store on an empty database is the role that owns the
tables and applies the policies. That role needs CREATE on the target schema;
nothing else is required, because a table's creator is its owner and ALTER TABLE
... FORCE ROW LEVEL SECURITY and CREATE POLICY are owner privileges. FORCE
means that owner is still constrained by the policies afterwards. Roles that only
connect later need SELECT, INSERT, UPDATE, and DELETE on the four tables,
which the owning role grants. Do not pre-create the tables from a migration tool:
an objects table without the engine's stamp is refused at construction.
Database- or schema-per-tenant is also valid when stronger physical isolation is worth the operational cost. The shared-schema option is for deployments where a single database is the better fit.
Schema version compatibility¶
SQLite stamps each file with SCHEMA_VERSION through PRAGMA user_version when it
creates it; 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 ConflictError
and code STORE_VERSION_UNSUPPORTED, naming the engine and store 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.
Moving across a schema version¶
There is no in-place upgrade. The supported path is drop and recreate: the
engine creates the current schema on an empty store, and the application re-ingests
from its sources. This is the intended shape for an ontary store, which is a
governed projection of source systems, not the system of record; client.ingest
and client.ingest_links rebuild it. If the store holds facts that exist nowhere
else (Action-written rows, audit history you must keep), export them with the old
ontary version before dropping.
For SQLite, move the old file aside and let the engine create a new one at the same path.
For PostgreSQL, drop the four engine tables in the schema the store connects to
(the search_path of the DSN, or public), then construct the store again:
DROP TABLE IF EXISTS audit_log, links, objects, schema_meta;
Indexes and row-level-security policies belong to those tables and go with them.
The next PostgresStore(...) sees no schema_meta, no objects, creates the
schema at the engine's SCHEMA_VERSION, re-applies RLS when rls=True, and stamps
it. A first deployment on an empty database needs none of this: it creates the
schema on construction.
The stamp is a whole-schema fingerprint, not a release number. It changed from 9
to 10 between the last ontos release and ontary 0.11.0, and a database stamped
9 is refused by every ontary version. It changed from 10 to 11 in 0.15.0, when
the idx_objects_live_id partial unique index made "at most one live row per
primary key" a storage constraint; a store stamped 10 is refused by 0.15.0
and later and is rebuilt by re-ingest like any other schema move. It changed from 11
to 12 in 0.16.0, when audit_log gained the unscoped_params column (#35); a
store stamped 11 is refused by 0.16.0 and later in the same way. It changed from 12
to 13 in 0.17.0, when audit_log gained the error_code column (#49); a store
stamped 12 is refused by 0.17.0 and later in the same way. It changed from 13
to 14 in 0.19.0, when audit_log gained the events column (#47); a store
stamped 13 is refused by 0.19.0 and later in the same way. Check the engine's number with
ontary.store.SCHEMA_VERSION and the store's with
SELECT value FROM schema_meta WHERE key = 'schema_version' before an upgrade so
the re-ingest is planned rather than discovered at startup.