Tenancy
How Better Compute maps onto Better IAM tenants, with the root tenant as the platform, pools offered to organizations, and projects inside a tenant.
Better Compute has no tenant table of its own. A is a Better IAM tenant id, every
compute record carries the id of the tenant that owns it, and every method takes a tenantId. Better IAM's
tenant tree gives compute its structure:
- The root tenant is the platform. It owns the shared fleet and decides fleet-wide operations.
- Organization tenants are customers. They own their reservations, instances and projects, and any hardware they bring.
- Child tenants below an organization exist only when you need billing isolation.
Inside a tenant, compute's group work without creating more tenants.
What lives where
| Root tenant (platform) | Organization tenant |
|---|---|
Platform sites (shared and dedicated), their pools and nodes | Reservations and instances |
| of platform pools to organizations | Projects |
| Public images and profiles | Private images and profiles |
| Platform provider accounts and secrets | custom sites with their pools and tenant-owned nodes (bring your own hardware) |
| Billing meters and fleet roles | Webhooks, tenant secrets and tenant provider accounts |
The method table encodes this split. sites.create decides on compute-fleet for shared and dedicated sites,
which must name the root tenant (another tenantId is INVALID_INPUT), and on compute-tenant for an
organization's custom site. pools.create decides on compute-fleet for a platform pool and on
compute-tenant for a pool on the organization's own custom site. See
Actions and resources.
The root tenant
Tell compute which Better IAM tenant is the root, either as betterCompute({ rootTenantId }) or through the port
with bridge.embedded(iam, { rootTenantId }). When both are given they must agree, and a missing or malformed id
is INVALID_CONFIG.
The root tenant is special in three ways:
compute-fleet/globalresolves only in the root tenant, so fleet-wide methods (system.selfCheck,quotas.set,fleet.*,rollouts.*, platformsites.createandpools.create) are decided there, whatever tenant their input names.- Root-tenant callers pass compute's cross-tenant pre-check for any tenant. Better IAM then decides: it allows a root-tenant caller into another tenant only through its root override, which needs root authority and an MFA-verified user session, and it audits every override. Platform support on a customer's objects happens only this way.
- The billing meters live in the root tenant, and Better IAM resolves them for every tenant below it.
Offering pools to organizations
A platform pool exists in an organization's view only once the platform offers it. pools.offer writes a
pool_offers row with the organization's quota and priority, and a projection row that makes the pool resolve
in that tenant:
await compute.api.pools.offer(rootOperator, {
tenantId: rootTenantId, // the pool's owner
poolId,
toTenantId: acmeTenantId,
quota: { gpu: 16 }, // per resource; a resource left out grants 0
priority: 10, // -1000 to 1000, default 0
});- The call decides
compute:pools:shareon the pool. Offering again updates the quota and priority. - A pool holding
tenant-ownednodes, or a single-tenant sharing mode (timeslice,mps), can be offered only to its owner. A pool withpolicy.singleTenant: true(required for VPS container runtimes) can be offered to one tenant besides its owner, withoutincludeDescendants; another offer isINVALID_STATE. pools.unofferremoves the pool from the organization's view and drops its quota to zero. Live reservations keep their charge; nothing new fits. A pool cannot be unoffered from its owner (INVALID_INPUT).includeDescendants: trueis stored on the offering, but this release writes the projection row only fortoTenantId, so child tenants do not see the pool yet. PLAN.md expands offerings to descendants from a tenant tree cached fromiam:tenants:*events; until then, offer the pool to each child tenant that needs it.
Organizations then share the pools they were offered with their own teams through Better IAM relationships. See Pool relations and Pools.
Visibility is the projection
What a tenant can see is exactly what compute's holds in its view: one row per resource type, id and tenant. List queries filter on the same rows, and the resolver answers only from them.
| Resource | The views it exists in |
|---|---|
| Platform pool | The root tenant, and each tenant it is offered to |
Pool on a custom site | Its owner tenant |
| Node | Its owner tenant |
| Site | Its owner tenant |
| Reservation, instance, project | Its owner tenant |
| Image | Its owner tenant. images.promote writes a row under the wildcard tenant *, PLAN.md's public row; the resolver in this release matches a row's tenant exactly. |
Inside your own tenant, an id that is not in your view resolves to NOT_FOUND. An id in another tenant never gets
that far: compute refuses the caller with ACCESS_DENIED before reading its database.
Projects
A project is an app-owned grouping inside one tenant, stored in compute's projects table:
CREATE TABLE projects (
id text PRIMARY KEY, -- prj_...
tenant_id text NOT NULL,
name text NOT NULL, -- unique in the tenant
state text NOT NULL, -- active | archived
version bigint NOT NULL,
UNIQUE (tenant_id, name)
);const project = await compute.api.projects.create(credential, {
tenantId,
name: 'ml-research', // a DNS label, at most 63 characters
idempotencyKey: 'create-ml-research',
});projects.createdecidescompute:projects:manageoncompute-tenant/{t}. It is idempotent per tenant, caller and key: the id derives from them, the same request again returns the project without a second decision, and the same key with a different name isCONFLICT. A second project with the same name isCONFLICTtoo.- The creator becomes the project's
ownerIdin the projection. Membership is a Better IAM relationship oncompute-project/{id}, with the relationsmemberandadmin. projects.getdecidescompute:projects:readon the project;projects.listfollows the list recipe and filters bystate.projects.archive(v0.2) decidescompute:projects:manageon the project. It setsstatetoarchived, bumpsversionand publishesproject.archived(projectId,name); archiving an archived project returns it unchanged, with no event. Archiving only changes the state: compute does not check it when a reservation or instance names the project.
Reservations and instances take an optional projectId, which becomes the projectId attribute of their
resource and the project tag of their usage windows. usage.list with a projectId decides
compute:usage:read on the project instead of the tenant, so project members can read their project's usage:
{
"sid": "ProjectMembers",
"effect": "allow",
"actions": ["compute:projects:read", "compute:usage:read"],
"resources": ["compute-project/*"],
"conditions": { "ArrayContains": { "resource.relations": "member" } }
}Compute checks only that a reservation's projectId is a prj_ id. A reservation's parent is its pool, not its
project, so a policy cannot test project membership when it decides reservations.create. Restrict which
projects a group may charge with resource.projectId conditions instead.
Child tenants for billing isolation
Create a Better IAM child tenant under an organization only when a unit needs its own bill. Better IAM grants
nothing downward, so a child tenant needs its own role bindings and, in this release, its own pools.offer.
Everything else about the organization's work fits in projects.
Quotas, flags and residency
- Quotas live in compute, because Better IAM's tenant limits are a fixed set. They are per tenant, pool and
resource (
gpu,vcpu, and so on), set by offerings and byquotas.set(v0.2, decided oncompute-fleet), and read withquotas.get. An admission that would exceed one fails withQUOTA_EXCEEDED. See Quotas and usage. - Flags such as
compute.h100are Better IAM feature flags. Policies test them throughtenant.features, and code reads them throughIamPort.featureEnabled, backed by a cache that holds each tenant and key for up to 5 minutes and drops entries onfeature:*events. - Residency waits for Better IAM to expose a tenant region. The offerings table already has an
allowed_regionscolumn, butpools.offerdoes not accept it and nothing enforces it yet. Until then, restrict regions in policy withresource.region, as thegpu-userexample does.
Suspended and deleted tenants
Compute mirrors the Better IAM tenants that are not active in its own compute_tenant_states table, owned by the
built-in tenancy module (migration tenancy/0001_tenant_states):
CREATE TABLE compute_tenant_states (
tenant_id text PRIMARY KEY,
state text NOT NULL, -- active | suspended | deleted
reason text, -- the IAM action that changed it
updated_at timestamptz NOT NULL,
teardown_done_at timestamptz -- set once every module released a deleted tenant
);The IAM reaction
The rows are written by the tenant reaction of the IAM inbox, on iam:tenants:update and iam:tenants:delete.
The server ships the handler, tenantStateHandler, and better-compute serve installs it as the default tenant
reaction when the configuration's host sets host.iam. Give it host.iam.readTenants so suspensions and
deletions cascade (anything but a function is INVALID_CONFIG). iamTenantReader from @better-compute/iam
builds one over a Better IAM instance or client:
import { iamTenantReader } from '@better-compute/iam';
export const host = defineHost(async () => ({
iam: {
bridge,
// The tenant and every tenant below it, with effective statuses, read as root.
readTenants: iamTenantReader(iam, { credential: rootCredential }),
},
}));iamTenantReader(iam, { credential, maxTenants? }) uses only tenants.get and tenants.listChildren, so iam can
be either of:
- Embedded:
betterIam()'s result (TenantReaderHost); the credential is the first argument of each call. - Remote: a Better IAM HTTP client,
createIamClient()from@better-iam/client(TenantReaderClient); the credential goes into each call's headers. A credential IAM refuses fails the read; it never reads asmissing.
import { createIamClient } from '@better-iam/client';
const readTenants = iamTenantReader(createIamClient({ baseURL: 'https://iam.example.com' }), {
credential: rootCredential,
});Either way:
credentialis a root credential (compute's service account in the IAM root tenant), or a function returning one: only root reads suspended and deleted tenants and every tenant's children.- Each status is the tenant's effective one, the worst of its own and its ancestors' (
active<pending<suspended<deleted<missing). Better IAM writes a deletion into every descendant's own status, but a suspension only into its decisions, so a child of a suspended tenant reads assuspendedhere. - A tenant IAM no longer knows is
missing, and only IAM's own "Tenant not found" answer means that. Any other error, a bare 404 from a proxy or a wrong base path included, throws and is retried, so it can never read as a deleted tenant. A subtree larger thanmaxTenants(default 10,000) is refused withINVALID_STATE.
better-compute dev --sim with the embedded Better IAM wires iamTenantReader in by itself, so suspending or
deleting a tenant there reaches its whole subtree.
host.iam.handlers.tenant replaces the default. A server you build yourself adds the handler to what it gives
bridge.setHandlers(), which replaces the whole set:
import { iamTenantReader } from '@better-compute/iam';
import { tenantStateHandler } from '@better-compute/server';
bridge.setHandlers({
...handlers, // agent, identity, sessions, ...
tenant: tenantStateHandler(compute, {
readTenants: iamTenantReader(iam, { credential: rootCredential }),
}),
});readTenants(tenantId)returns{ tenantId, status }for the tenant acted on and its descendants, withstatusone ofactive,pending,suspended,deletedormissing(IAM no longer knows it). A suspension or deletion cascades to descendants, and the audit event names only the tenant acted on, so compute re-reads the subtree instead of trusting the event.suspendedmaps tosuspended;deletedmaps todeleted; anything else isactive.missingonly confirms a tenant already recorded as suspended or deleted: it never starts a teardown by itself (so an id IAM audits underiam:tenants:updatewithout being a tenant, such as an invitation's, is never torn down). The root tenant is never suspended or deleted, whatever IAM answers.- Without a reader, only
iam:tenants:deleteis applied, and only to the named tenant; an update changes nothing. The handler queues subtree syncs only when it was given a reader, and a sync key that reaches a process without one fails withUNAVAILABLEand is retried with back-off rather than dropped. - Each change publishes
tenant.state-changed(tenantId,state,previous,reason) ontenant/{id}. A replay changes nothing, and a tenant reactivated in IAM becomesactiveagain.
No IAM call in the inbox transaction
The handler runs inside the IAM inbox's drain transaction, which holds its inbox rows FOR UPDATE, so it makes no
IAM call there. It only writes SQL:
- On
iam:tenants:delete, the named tenant is recorded asdeletedat once, no IAM read needed, and refused from that commit on. - On every update and delete, it queues the key
sync:<tenantId>for thetenancyreconciler, and it handsreadTenantstocompute.tenants.setReader().
The reconciler then reads the subtree through the reader outside any transaction (syncTenantStates) and applies
it in a transaction of its own (reason iam:tenants:sync). When IAM cannot answer, the read throws and the key is
retried with back-off: a suspension or deletion is delayed while IAM is down, never dropped, and compute never
fails open. As a backstop, the tenancy.resync reconciler, separate so it never delays syncs and teardowns,
re-reads the whole tree from the root tenant every 15 minutes (reason iam:tenants:resync); past the reader's size
limit it re-reads only the tenants already refused.
What the envelope refuses
Every call passes a tenant gate right after the cross-tenant pre-check, before compute's database or IAM decision:
| Tenant state | Reads | Mutations |
|---|---|---|
active | allowed | allowed |
suspended | allowed | TENANT_SUSPENDED (403) |
deleted | TENANT_SUSPENDED (403) | TENANT_SUSPENDED (403) |
The gate checks the caller's tenant and the tenants the call acts in; the error's details carry tenantId and
state. Root-tenant callers may still act in a suspended tenant and read a deleted one, but never write into a
deleted tenant, since what they created after the teardown would never be torn down. The gate answers from memory:
each process loads the non-active states at start, or on its first call when it never ran initialize() (an
API-only replica started with --no-migrate; compute.tenants.ready() loads it up front), and reloads them every
5 seconds. The process that applied a change reloads before its next call (compute.tenants.invalidate(), a
generation counter, so a load already in flight cannot swallow it). Another process may therefore admit a suspended
tenant's mutation for up to 5 seconds. Operations built without a deployment (unit harnesses) get noTenantGate,
which refuses nothing. A reload that fails is reported once per run of failures,
not every 5 seconds, and the timer stops once the store is closed (a one-shot CLI command closes it without
stopping compute).
Teardown of a deleted tenant
A deletion enqueues the tenancy reconciler for the tenant. It calls every hosted module's tenantDeleted hook,
plugins included; a module that answers SYNC_AGAIN is asked again by the 30-second sweep, which also recovers
a lost work item and retries the longest-waiting teardowns first (teardown_attempted_at, migration
tenancy/0002_teardown_attempts). When every hook answers DONE in one pass, the reconciler sets
teardown_done_at and publishes tenant.torn-down. The root tenant is never torn down, whatever a row says.
| Module | What it releases (idempotent, in batches of 100) |
|---|---|
capacity | Unstarted reservations cancelled, started ones released now (bookings and quota back); offers to the tenant withdrawn (quotas zeroed, pool.unoffered to the owner); pools it owns archived, with every offer withdrawn (pool.archived), while other tenants' live bookings there run to their end; its idle quota rows deleted. A dedicated-root lease is released this way too. |
provision | Active workflows aborted (their cleanup still runs); private images and profiles soft deleted with their authz rows; image signers revoked. Images it promoted to public stay, for the tenants that deploy them. |
metal | Every bare-metal node it owns retired, one legal transition per sweep: ready, adopted, enrolling and failed retire at once; deployed and rescued machines are released (wiped) first; a machine mid-workflow is aborted (it fails with TENANT_DELETED), then retired; releasing and retiring machines finish first. |
vps | Admission drafts dropped; every live instance set to desiredState: 'deleted' (100 per pass, instance.deleting with reason tenant deleted) and torn down through the ordinary lifecycle: runtime teardown, inline allocation released, IP freed, quota refunded. SYNC_AGAIN until every instance is deleted. |
Better Compute is created by Sean Filimon
Last updated
Policies
Write Better IAM policies for Better Compute, from the gpu-user and agent-guardrails examples to pool relations, resource attributes and conditions.
Billing and usage
How Better Compute meters reserved time into hourly usage windows, records them on Better IAM meters with poison isolation, and where budgets apply.