RFD-0020 — Per-tenant kernel runtime — shared base + per-tenant overlay
Question
The kernel serves many tenants concurrently. Should each tenant get an isolated kernel instance with its own copy of every loaded ontology, should everything share one global state with tenant-as-filter, or is there a hybrid?
Decision
Per-tenant kernel runtime: shared immutable base + per-tenant overlay.
- A shared immutable base (UFO + Core + std) lives once under a reserved
SHARED_BASE_TENANT_ID(UUID nil). Every tenant reads from this base; nobody writes to it. - Each tenant has a per-tenant overlay that adds tenant-specific concepts, roles, properties, rules, and compute registrations. The overlay is mutable per-tenant; cross-tenant writes are impossible by construction.
LayeredSchemaViewresolves overlay-first with base fallback in a single SQL predicate:WHERE tenant_id IN (SHARED_BASE_TENANT_ID, $t).- Per-tenant
Kernelinstances exist asArc<RwLock<BTreeMap<TenantId, Arc<RwLock<Kernel>>>>>inKernelState. Lazy load from Postgres + soft-cap eviction. - Tenant context propagates through the API layer via
X-Tenant-Idheader;TenantContextV2extractor binds tenant scope per request. - Cross-tenant isolation is enforced at multiple layers: type-level (
&mut selfonKernelcarriestenant_id), runtime (every SQL scopedWHERE tenant_id IN (...)), integration-test verified. - Per-tenant domain registration via static Rust linking + per-tenant overlay registry. Form 2 / Form 3 computes (RFD-0006) live in the per-tenant overlay; name collisions impossible by construction.
- Standpoints are tenant-local; primary standpoint sourced from tenant config.
Rationale
Shared base means UFO loads once. A naive isolated-instance design would have re-loaded every foundational ontology for every tenant — gigabytes of duplicated state, unbounded Postgres pressure. The shared base is the same content, read from one canonical location, with the SQL machinery making the read transparent.
Per-tenant overlay means tenant data stays tenant-local. Tenant A’s tax law amendments must not be visible to tenant B. The overlay scoping enforces this by default; cross-tenant reads require explicit join logic that has nowhere to live in normal code paths.
One-SQL read via reserved tenant ID. The shared base is queryable through the same SQL the per-tenant query uses, just with a wider IN predicate. No conditional logic in the read path; no “if shared else tenant” branching that would have produced the kind of bug we just paid an audit to find.
Type-level tenant carrying. Kernel instances carry their tenant ID in the type. A function that wants to operate on Kernel data must take &mut Kernel, which means it has a specific tenant in scope. There’s no API that lets you accidentally operate cross-tenant.
Consequences
KernelState(kernel/api/src/state.rs) maintains the per-tenant Kernel map with lazy load + soft-cap eviction.- All v2 API endpoints route through
TenantContextV2. Write paths callrequire_principal(); missing principal surfaces asOE6005 PrincipalRequired. - Domain registration via static Rust linking. Per-tenant overlay registers in
RustComputeRegistry. - Kernel Kripke commitments (RFD-0018’s modal operators) are preserved under per-tenant overlay; tenant-A and tenant-B see consistent modal evaluation against their respective overlays.
- Cross-tenant queries are not a feature. If a future use case requires cross-tenant data, it would need an explicit RFD authorizing the boundary crossing.