ADR 0003: Schemas are first-class catalog namespaces
ADR 0003: Schemas are first-class catalog namespaces
Status: Accepted
Date: 2026-10-24
Scope: orbit-rs
Amended by: ADR 0012 (item 6: pg_catalog and information_schema are real schemas in this step)
Amended by: ADR 0014 (item 4: the rolling-upgrade rule is an operator-attested setting, not a version check)
Deciders: Ravindra Boddipalli (accepted 2026-10-24)
Context
PostgreSQL resolves every relation as (schema, name) through a per-session search_path. In Orbit-RS today:
- The parser already handles the schema qualifier (
TableName { schema, name }) andCREATE/DROP/ALTER SCHEMA. The unified executor only echoesCREATE SCHEMAback (sql/execution_strategy.rs). - The durable engine keys tables by bare lowercase name:
table_keyinsql/durable/ddl.rsreturnsn.name.to_lowercase(), andState.tables,State.ids,State.viewsand the sequence map are all keyed by that string.acme.invoicesandglobex.invoicescollide. search_pathis a session variable that reports"$user", publicand is never read.ALTER TABLE ... SET SCHEMAis a no-op.pg_namespaceandcurrent_schema()return fixed values.DROP SCHEMAof a schema that does not exist succeeds, where PostgreSQL fails (conformance12_ddl.slt, line 140).
Two properties of the engine shape the design:
- DDL is persisted as text and replayed in order (
Op::Ddl(String)insql/durable/persist.rs, applied byapply_ops). The replay has no session, so today’s text carries nosearch_path. - Row changes are keyed by a numeric table id, not by name (
Op::PutRow { table: u32, .. }), and the table-by-id map is the only place the name is needed on the apply path.
This blocks hosting several tenants in one cluster (ADR 0009, tenant isolation on orbit-rs, depends on it) and is a prerequisite for most ORM and tool compatibility.
Decision
- Catalog key. A relation’s key becomes the qualified, lowercased string
schema.name.State.tables,ids,viewsand the sequence map keep their string keys, so the roughly 55 sites that pass a key through unchanged keep working.TableandViewgain aschemafield.Stategains a set of schemas that always containspublic. - Resolution. A resolver built from the session’s
search_pathreplacestable_key. An explicit qualifier wins. An unqualified name for an existing relation resolves to the first schema in the path that contains it. An unqualified name for a new relation goes to the first schema in the path that exists, and fails with3F000if none does."$user"expands to the session user when a schema of that name exists.SET,SET LOCAL,RESETand transaction rollback ofsearch_pathbehave as in PostgreSQL. - Persistence and replication, with no data migration.
Op::Ddl(text)replays withsearch_path = [public]. A new variantOp::DdlIn { search_path, text }is written only when resolving the statement depends on anything other thanpublic. Old logs therefore load unchanged: every existing relation lands inpublic. Sequence names inSeqHighWaterwithout a.belong topublic. Row ops are untouched. - Rolling upgrade rule. Until the first non-
publicschema is created, the log contains only records an older node can apply. Creating the first schema is refused with a clear error until every node reports the new version. Nodes must be upgraded before schemas are used. - DDL surface.
CREATE SCHEMA [IF NOT EXISTS] [AUTHORIZATION],DROP SCHEMA [IF EXISTS] [CASCADE | RESTRICT](2BP01when not empty,3F000when missing),ALTER SCHEMA ... RENAME TO,ALTER TABLE ... SET SCHEMA, qualified names in every statement, cross-schema foreign keys and views. Errors and SQLSTATEs match PostgreSQL (3F000,42P06,2BP01). - Catalog.
pg_namespacereturns real rows (pg_catalogoid 11,public2200, and a counter for the rest);pg_class.relnamespace,information_schema.*.table_schema,current_schema(),current_schemas(bool)andSHOW search_pathreport the real state.pg_catalogandinformation_schemastay virtual in this step and are always visible. - The legacy executor path must not accept
CREATE SCHEMAand drop it. Either it routes to the durable engine or it fails with0A000.
Out of scope here, and why it matters
Schemas are a namespace, not a security boundary. A connection can qualify another schema’s names or SET search_path. Real tenant isolation (Symphony ADR 0003) also needs roles, schema ownership, GRANT/REVOKE USAGE and table privileges, which orbit-rs does not implement. That is a separate record. Until it exists, schema-per-tenant must not be described as isolation.
Open items, resolved by the owner on 2026-10-24
- Rolling-upgrade guard: yes. Item 4 stands: the first non-
publicschema is refused until every node reports the new version. pg_catalogandinformation_schemaas real schemas: yes, and in this step, not later. See ADR 0012, which amends item 6.- Privileges (roles and
GRANT): yes, as the next record: ADR 0013.
Consequences
- Touches the core data structures, the replay path and the catalog views; no stored data is rewritten.
- A node that predates this change cannot apply
DdlIn. Item 4 prevents that from happening silently. - Gate for every step: the
orbit-serverlib tests (baseline run 2026-10-24), the eight Symphony flow cases, and the conformance ratchet (481 of 533) must not fall.
Verification
- New corpus file
18_schemas.slt, recorded from PostgreSQL 18, covering creation, resolution order,SET search_pathand its rollback, cross-schema foreign keys,DROP SCHEMAwith and withoutCASCADE,ALTER ... SET SCHEMA, and the catalog views. - A recovery test: objects in two schemas,
kill -9, restart, same state; and the same on a three-node cluster across a leader kill. - A replay test: a log written before this change loads with every relation in
public.