ObjectStackObjectStack

Tenancy postures and membership models

Choose single, group or isolated tenancy: how OS_TENANCY_POSTURE resolves, the membership policy for new users, and the degraded-tenancy boot guard.

An ObjectStack deployment runs in one of three tenancy postures. The posture is the single fact that decides what the authorization kernel's Layer 0 organization wall enforces, how new users are placed into an organization, and which organization-management UI is available.

The three-posture spectrum is ADR-0105 D1; it generalizes the two-valued tenancy mode of ADR-0093, whose tenancy service, membership lifecycle and degraded-tenancy boot guard all still apply — widened to the three postures.

Renamed, not re-configured

The posture formerly called multi is now isolated, and the authoritative knob is OS_TENANCY_POSTURE, not OS_MULTI_ORG_ENABLED. Existing deployments keep their behaviour with no config change: the legacy boolean is still honoured as a fallback input to posture resolution. See How the posture resolves.


The three postures

single (default)groupisolated
Layer 0 organization wallnone — the layer is inertorganization_id IN accessible_org_ids (union / MOAC)organization_id = <active organization>
Read reachthe whole dataset, subject to RBACevery organization the caller currently holds a membership inthe active organization only
Shapeone logical tenant; sub-units modelled as business units in one treeorganizations are membership / invitation boundaries over one shared datasetlegal-entity / sovereignty isolation — the hard wall
organization_id filled in on writenoyesyes
Enterprise @objectstack/organizationsnot neededrequiredrequired
Default organizationone bootstrapped "Default Organization"owned by the enterprise runtimeowned by the enterprise runtime
Org-management UI (create / switch org)hiddenshownshown
Who writes the environment's metadataany manage_metadata holderthe platform operator onlythe platform operator only

Only the wall's predicate differs between group and isolated. Everything about how it composes is identical: Layer 0 is AND-composed ahead of, and independently of, business RLS, and no permissive business policy, sharing rule or viewAllRecords / modifyAllRecords superuser bit can widen it (ADR-0095 W1/W2, ADR-0105 D2/D4). A walled posture with no resolvable scope — no active organization under isolated, an empty membership set under group — fails closed to zero rows rather than falling through.

Under a wall, the environment's metadata is the platform operator's

A walled deployment is one environment with one schema and one catalog (ADR-0131 D12 items 7–8, D11). Its metadata (objects, fields, views, flows, apps, dashboards, translations, packages, positions, permission sets, capabilities, every type) carries no organization, so a write lands for every organization at once. Under group and isolated only the platform operator (the PLATFORM_ADMIN rung: an address declared in OS_PLATFORM_OWNER_EMAIL) and the engine's own writes (seeders, installers, migrations) write it. Holding manage_metadata inside an organization does not admit a write: the /meta write doors (save, reset, publish, rollback), the install-wide stored rewrite (POST /meta/_migrate-stored), every /packages write, install-local install, upgrade and uninstall, and the /automation definition writes refuse such a caller 403, naming the posture.

  • Headquarters IT authors the environment's metadata as the platform operator: declare its administrators in OS_PLATFORM_OWNER_EMAIL. A manage_metadata grant scoped to an organization authors nothing under a wall.
  • An organization's administrators assign: they place their own members in the catalog's positions and permission sets, and manage their organization's data. They define nothing.
  • An organization that needs metadata of its own is given an environment of its own.

Under single the organization is the environment, and every manage_metadata holder writes as before.

single is not "multi-org with the walls left standing": Layer 0 contributes no filter at all, and the platform's own tenant-scoped RLS policies are stripped. Policies you authored that mention current_user.organization_id are retained and fail closed (ADR-0105 D3) — only the platform's own wildcard tenant policies are dropped.

Open code, entitled activation

Both walled postures require the enterprise @objectstack/organizations runtime to activate. The wall's implementation is open — the Layer 0 compiler, accessible_org_ids resolution, and the write-side validation that rejects a forged organization_id all ship in open packages — but enabling a multi-organization posture is a commercial capability (ADR-0105 D12). Filling in an absent organization_id on write is the enterprise runtime's job; validating a supplied one is a security property and stays open.

The installed runtime may narrow this further: the org-scoping service can declare supportedPostures, and the open core fails closed on any walled posture it does not list. A runtime that declares nothing entitles both.


How the posture resolves

OS_TENANCY_POSTURE is the authoritative knob; OS_MULTI_ORG_ENABLED is a back-compat input consulted only when the posture is unset.

OS_TENANCY_POSTUREOS_MULTI_ORG_ENABLEDRequested posture
single / group / isolatednot consultedthat posture
multi (legacy spelling)not consultedisolated
unset, or empty/whitespaceany value other than a case-insensitive falseisolated
unset, or empty/whitespaceunset, or falsesingle
anything elsenot consultednone — resolution throws rather than guessing

Values are trimmed and lower-cased before matching, so Isolated and group resolve. An unrecognized value is never coerced to a weaker posture: resolution throws (Invalid OS_TENANCY_POSTURE=…) and os serve reports the error and exits non-zero rather than serving with a posture it could not parse.

Two consequences worth stating plainly:

  • The legacy boolean is an input, not a second switch. With OS_TENANCY_POSTURE set, OS_MULTI_ORG_ENABLED is not read at all — setting it as well changes nothing, and unsetting it takes nothing away. A deployment that sets only OS_TENANCY_POSTURE=isolated is fully walled.
  • Never gate application code on OS_MULTI_ORG_ENABLED. It reports false on exactly the deployment shape the documentation recommends (posture set, boolean unset), so any gate keyed off it inverts its own contract there. Ask the posture instead — or, inside a running kernel, the tenancy service, which additionally knows whether the requested wall is actually standing. Two shipped defects came from getting this wrong, one of which 403'd organization/create on a fully walled deployment.

One source of truth: the tenancy service

Rather than re-deriving "what posture is this?" from an env flag, a service probe, or row counts, the platform exposes a single tenancy kernel service (registered by plugin-auth):

import type { TenancyPosture } from '@objectstack/spec/security';

interface TenancyService {
  /** The posture actually IN FORCE — an unenforceable request resolves to `single`. */
  readonly posture: TenancyPosture;
  /** What the operator asked for: OS_TENANCY_POSTURE, else OS_MULTI_ORG_ENABLED. */
  readonly requestedPosture: TenancyPosture;
  /** True iff an organization wall is actually enforced. */
  readonly isolationActive: boolean;
  /** True iff a wall-enforcing posture (`group` or `isolated`) was requested. */
  readonly requested: boolean;
  /** `requested && !isolationActive` — asked for a wall, did not get one. */
  readonly degraded: boolean;
  /** Single-org default org; `null` under any walled REQUEST, degraded included. */
  defaultOrgId(): Promise<string | null>;
}

posture and requestedPosture are the two facts that can disagree, and the difference is the whole point: a request that cannot be enforced resolves to posture: 'single' and raises degraded, because a wall that is not standing is not a wall.

/auth/config reports features.tenancyPosture (the effective posture), features.multiOrgEnabled (true iff that posture enforces a wall) and features.degradedTenancy, so the console renders the correct UI. The organization/create gate is derived from the same call, so the API can never refuse a capability /auth/config advertises — or mint an organization no engine walls. Concretely: creating additional organizations is refused (403) whenever the effective posture is single, including a degraded deployment that only booted because OS_ALLOW_DEGRADED_TENANCY=1.


Membership: how new users join an organization

Every human user should end up as a member of an organization (a sys_member row). A single reconciler owns this invariant — it runs as a user.create.after hook, so every creation path is covered uniformly: email sign-up, the admin Create User and Import Users flows, and SSO just-in-time provisioning.

The reconciler:

  • yields to any membership that already exists (e.g. one created by an invitation, add-member, SSO provisioning, or a host hook) — it never creates a second membership;
  • binds only to an unambiguous target org — that is tenancy.defaultOrgId(), which returns the single-org default organization under single and null under any walled request (invitations, add-member and SSO provisioning own membership there, where guessing an org would risk the wrong tenant);
  • is best-effort — a failure logs a warning and never fails user creation.

The "walled request" wording is deliberate: a degraded deployment asked for a wall and did not get one, and the safe reading of that is "I don't know which org this user belongs to", not "everyone belongs to the only org I can see". So defaultOrgId() returns null there too.

`add-member` is a route — here it is

The add-member named above is POST /api/v1/auth/organization/add-member, an ObjectStack mount wrapping better-auth's server-only addMember. Under a walled posture it is the only way to attach an existing account to an organization: the reconciler declines to guess a target org, generic sys_member writes are suppressed under the ADR-0010 lock, and invitations need an email round-trip that phone-number-only accounts cannot complete. Its admit set is platform admin only — an organization owner or administrator is refused 403 PERMISSION_DENIED. See Attaching an existing user to an organization.

Membership policy

PolicyBehavior
'auto' (default)New member-less users are bound to the single-org default organization.
'invite-only'Users are never auto-bound; membership comes only from invitations, add-member, SSO provisioning, or host hooks. Choose this for a deployment whose end-users are deliberately not teammates.

Set it as an AuthPlugin option, as the auth.membership_policy platform setting, or via the OS_AUTH_MEMBERSHIP_POLICY env override:

new AuthPlugin({ membershipPolicy: 'invite-only' /* … */ });

The setting is read live on every use, so an admin changing it reaches both the sign-up reconciler and the backfill below without a restart. An unrecognized value is rejected rather than coerced: the deployment keeps its current policy and the reconciler refuses to auto-bind under it.

Membership is not access control in the single posture

With no wall to enforce, membership does not gate data access — RBAC permission sets do. Membership drives the Members list, the active organization a session resolves, and invitations.

Backfill for pre-existing users

On boot (kernel:ready), deployments with membershipPolicy: 'auto' backfill memberships for any pre-existing member-less users (e.g. accounts created before the reconciler existed), binding them to the default organization. It is bounded, idempotent, and self-guards: it no-ops under invite-only, and under any walled request defaultOrgId() is null so there is nothing to bind to. Opt out with OS_SKIP_MEMBERSHIP_BACKFILL=1.

There is a second, unrelated boot-time backfill

The platform also runs a seed tenancy repair at kernel:ready — a different mechanism that rewrites organization_id on business rows and merges autonumber counters. It is gated on the posture being single, so it is adjacent to this page's subject, but it is not this backfill: OS_SKIP_MEMBERSHIP_BACKFILL has no effect on it, and it has no opt-out of its own. See Seed Tenancy Repair.


Degraded tenancy: the boot guard

Requesting a walled posture without a working @objectstack/organizations package is dangerous: the organization wall cannot be enforced, so Layer 0 goes inert and every organization boundary becomes decorative — while the operator believes the deployment is walled. This applies to both walled postures; group is entitled exactly like isolated and degrades the same way.

The platform refuses to boot in this state:

  ✖ FATAL: tenancy posture 'isolated' was requested but @objectstack/organizations could not be loaded,
    so the organization wall is INACTIVE. Refusing to boot — a deployment that requested
    multi-organization isolation must not serve traffic without it (ADR-0093 D5).

    Fix one of:
      • add @objectstack/organizations (the enterprise multi-org runtime) to THIS APP
        — declare it in the app's package.json and install; the CLI resolves it from the
          app, not from the framework it is linked out of. …
      • set OS_TENANCY_POSTURE=single (or unset OS_MULTI_ORG_ENABLED) to run single-org, or
      • set OS_ALLOW_DEGRADED_TENANCY=1 to boot in an explicitly degraded single-org state.

So, three ways out:

  • add @objectstack/organizations (the enterprise multi-org runtime) to the app you are serving — declare it in that app's package.json and install it there. The CLI resolves the package from the served app, not from the framework it is linked out of, and being merely reachable through NODE_PATH or a hoisted workspace store is deliberately not enough; or
  • set OS_TENANCY_POSTURE=single (and unset OS_MULTI_ORG_ENABLED) to run single-org; or
  • set OS_ALLOW_DEGRADED_TENANCY=1 to boot anyway in an explicitly degraded single-org state.

If the package is already declared, the remedy bullet says only that — the declaration is not the problem — and hands the remedy to the cause: line below it, which is the one thing that measured what actually went wrong. There are two shapes of that, and they want opposite actions:

  • the install is broken — never installed, pruned away by a filtered deploy, or pointing at a dist that was never built. The cause names all three and tells you to repair the install rather than re-read package.json.
  • the install is fine and the runtime still could not be verified — the app declares the package under a link: / file: path, or a git or tarball URL, and the package sitting at that key carries a different name in its own package.json. This is neither an install nor a declaration fault, and the cause says so outright: re-running pnpm install changes nothing. A link: install pointing at exactly the directory the declaration names is verified by that path and loads normally; what reaches this message is the residue where neither the name nor the path ties the directory to the declaration — a file: directory install under pnpm (which lands in the virtual store rather than at the declared path), or a URL that names no on-disk location at all. The remedy the cause prints is to make the two names agree, from whichever end you own.

A present-but-refusing runtime is a different failure

If @objectstack/organizations is found and its plugin then refuses to mount, boot is refused unconditionally and the plugin's own message is printed verbatim. OS_ALLOW_DEGRADED_TENANCY does not get past that: the escape hatch means "the capability is ABSENT and I accept doing without it", not "a present runtime declined and I would like to ignore why".

What a degraded boot looks like

When you opt into the degraded state, it is surfaced where an operator looks — a loud yellow terminal boot warning and features.degradedTenancy: true in /auth/config — so degraded operation is always a visible, chosen state, never a silent one. The effective posture is single, so organization creation is refused there as well. (The /auth/config flag is there for any tooling that wants to render its own warning; the framework itself does not ship a console banner for this extreme, opt-in-past-a-refusal case.)

Upgrading?

A deployment that was silently degraded before this guard existed will fail to boot after upgrade. That is intentional — it was not actually walling anything. Either install the enterprise package or set OS_ALLOW_DEGRADED_TENANCY=1 to acknowledge the state. Deployments that adopted OS_TENANCY_POSTURE=group during the window when it self-activated are in the same position: group now requires the enterprise runtime.


Scheduled work is off until the deployment says otherwise

Time-triggered flows and packaged defineJob cron jobs do not run on a deployment that has not switched them on (OS_AUTOMATION_SCHEDULED_WORK_ENABLED), and that is true in every posture, including single and group. Whether a clock-driven workload is affordable is a fact about the deployment — its database, its tenants, its budget — that no package author can know, so it is not metadata and there is no spec key for it.

Once it is on, the posture decides what a time-triggered flow owes:

posturewhat an armed time-triggered flow needs
singlenothing. The deployment holds exactly one organization, the run carries no organization, and every tenant-scoped insert beneath it resolves that one organization the way a single-organization install always did.
groupoptionally config.organization. Declared, it behaves exactly as isolated below — bounding the sweep's query as well as its identity. Undeclared, the flow still arms: a timeRelative sweep reads group-wide and each run it launches acts as its own swept record's organization.
isolatedconfig.organization on the start node, naming the organization the run executes as. A flow that declares none is not armed and is named in the boot summary. There is no fan-out: a sweep wanted in N organizations is declared N times, and nothing ever chooses an organization for a flow.

group is walled like isolated — an organization-less system insert is refused under any wall — but that is not the whole picture, and it is why the two rows differ. group is one legal group over one shared database, where group-wide visibility and cross-organization workflow are inherent to the shape, so a group-level batch job is a capability of the posture rather than a boundary violation. What was open until 2026-09-16 was which organization such a run's inserts belong to; the answer is the swept record's own, which is the subject-first order sys_automation_run already used.

A record-less flow under group — a plain schedule cron with no sweep — has nothing to derive an organization from. It binds and runs, but any tenant-scoped row it writes (a notification, an inbox message) is refused at the write, by name, with the remedy. Declare organization on such a flow if it writes per-organization data; its bind line says so at boot. ⛔ Nothing falls back to a "default" organization — a wrong organization_id is silently authoritative to every report and export that filters by organization, which is worse than a refusal.

While the switch is off, such a flow is listed in the startup summary and in getTriggerBindingAudit() as disabled by deployment policy — not as a binding failure. Nothing about the flow needs fixing.

Environment variables

VariableDefaultEffect
OS_TENANCY_POSTUREderived from OS_MULTI_ORG_ENABLEDThe authoritative knob: single, group, or isolated (legacy spelling multi = isolated). An unrecognized value throws instead of falling back. Both walled postures require @objectstack/organizations.
OS_MULTI_ORG_ENABLEDfalseSuperseded by OS_TENANCY_POSTURE and still honoured only when it is unset: any value other than a case-insensitive false selects isolated.
OS_ALLOW_DEGRADED_TENANCYfalseBoot even when a walled posture is requested but the runtime is absent (degraded). Accepts 1 / true / on / yes. Does not cover a runtime that refused to mount.
OS_ORG_LIMITunset (unlimited)Cap on organizations a single user may own; organizations they were merely invited into never count against it. Only meaningful under a walled posture, since org creation is refused otherwise.
OS_AUTH_MEMBERSHIP_POLICYautoEnv override for the auth.membership_policy setting — auto or invite-only.
OS_AUTOMATION_SCHEDULED_WORK_ENABLEDfalseWhether package-authored scheduled work runs here at all — time-triggered flows and packaged defineJob cron jobs. OFF in every posture until set. Under single a time-triggered flow that is armed by it needs no config.organization and its runs carry none; under group the declaration is optional and an undeclared sweep acts as each swept record's own organization; under isolated it must declare one or it is not armed. See The acting organization.
OS_SKIP_MEMBERSHIP_BACKFILLunsetSet to 1 to skip the boot-time membership backfill.

See Environment variables for the full list.

On this page