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 }) and CREATE/DROP/ALTER SCHEMA. The unified executor only echoes CREATE SCHEMA back (sql/execution_strategy.rs).
  • The durable engine keys tables by bare lowercase name: table_key in sql/durable/ddl.rs returns n.name.to_lowercase(), and State.tables, State.ids, State.views and the sequence map are all keyed by that string. acme.invoices and globex.invoices collide.
  • search_path is a session variable that reports "$user", public and is never read. ALTER TABLE ... SET SCHEMA is a no-op. pg_namespace and current_schema() return fixed values.
  • DROP SCHEMA of a schema that does not exist succeeds, where PostgreSQL fails (conformance 12_ddl.slt, line 140).

Two properties of the engine shape the design:

  • DDL is persisted as text and replayed in order (Op::Ddl(String) in sql/durable/persist.rs, applied by apply_ops). The replay has no session, so today’s text carries no search_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

  1. Catalog key. A relation’s key becomes the qualified, lowercased string schema.name. State.tables, ids, views and the sequence map keep their string keys, so the roughly 55 sites that pass a key through unchanged keep working. Table and View gain a schema field. State gains a set of schemas that always contains public.
  2. Resolution. A resolver built from the session’s search_path replaces table_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 with 3F000 if none does. "$user" expands to the session user when a schema of that name exists. SET, SET LOCAL, RESET and transaction rollback of search_path behave as in PostgreSQL.
  3. Persistence and replication, with no data migration. Op::Ddl(text) replays with search_path = [public]. A new variant Op::DdlIn { search_path, text } is written only when resolving the statement depends on anything other than public. Old logs therefore load unchanged: every existing relation lands in public. Sequence names in SeqHighWater without a . belong to public. Row ops are untouched.
  4. Rolling upgrade rule. Until the first non-public schema 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.
  5. DDL surface. CREATE SCHEMA [IF NOT EXISTS] [AUTHORIZATION], DROP SCHEMA [IF EXISTS] [CASCADE | RESTRICT] (2BP01 when not empty, 3F000 when 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).
  6. Catalog. pg_namespace returns real rows (pg_catalog oid 11, public 2200, and a counter for the rest); pg_class.relnamespace, information_schema.*.table_schema, current_schema(), current_schemas(bool) and SHOW search_path report the real state. pg_catalog and information_schema stay virtual in this step and are always visible.
  7. The legacy executor path must not accept CREATE SCHEMA and drop it. Either it routes to the durable engine or it fails with 0A000.

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

  1. Rolling-upgrade guard: yes. Item 4 stands: the first non-public schema is refused until every node reports the new version.
  2. pg_catalog and information_schema as real schemas: yes, and in this step, not later. See ADR 0012, which amends item 6.
  3. 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-server lib 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_path and its rollback, cross-schema foreign keys, DROP SCHEMA with and without CASCADE, 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.