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) | group | isolated | |
|---|---|---|---|
| Layer 0 organization wall | none — the layer is inert | organization_id IN accessible_org_ids (union / MOAC) | organization_id = <active organization> |
| Read reach | the whole dataset, subject to RBAC | every organization the caller currently holds a membership in | the active organization only |
| Shape | one logical tenant; sub-units modelled as business units in one tree | organizations are membership / invitation boundaries over one shared dataset | legal-entity / sovereignty isolation — the hard wall |
organization_id filled in on write | no | yes | yes |
Enterprise @objectstack/organizations | not needed | required | required |
| Default organization | one bootstrapped "Default Organization" | owned by the enterprise runtime | owned by the enterprise runtime |
| Org-management UI (create / switch org) | hidden | shown | shown |
| Who writes the environment's metadata | any manage_metadata holder | the platform operator only | the 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. Amanage_metadatagrant 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_POSTURE | OS_MULTI_ORG_ENABLED | Requested posture |
|---|---|---|
single / group / isolated | not consulted | that posture |
multi (legacy spelling) | not consulted | isolated |
| unset, or empty/whitespace | any value other than a case-insensitive false | isolated |
| unset, or empty/whitespace | unset, or false | single |
| anything else | not consulted | none — 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_POSTUREset,OS_MULTI_ORG_ENABLEDis not read at all — setting it as well changes nothing, and unsetting it takes nothing away. A deployment that sets onlyOS_TENANCY_POSTURE=isolatedis fully walled. - Never gate application code on
OS_MULTI_ORG_ENABLED. It reportsfalseon 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, thetenancyservice, which additionally knows whether the requested wall is actually standing. Two shipped defects came from getting this wrong, one of which 403'dorganization/createon 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 undersingleandnullunder any walled request (invitations,add-memberand 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
| Policy | Behavior |
|---|---|
'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'spackage.jsonand 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 throughNODE_PATHor a hoisted workspace store is deliberately not enough; or - set
OS_TENANCY_POSTURE=single(and unsetOS_MULTI_ORG_ENABLED) to run single-org; or - set
OS_ALLOW_DEGRADED_TENANCY=1to 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 differentnamein its ownpackage.json. This is neither an install nor a declaration fault, and the cause says so outright: re-runningpnpm installchanges nothing. Alink: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 — afile: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:
| posture | what an armed time-triggered flow needs |
|---|---|
single | nothing. 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. |
group | optionally 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. |
isolated | config.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
| Variable | Default | Effect |
|---|---|---|
OS_TENANCY_POSTURE | derived from OS_MULTI_ORG_ENABLED | The 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_ENABLED | false | Superseded 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_TENANCY | false | Boot 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_LIMIT | unset (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_POLICY | auto | Env override for the auth.membership_policy setting — auto or invite-only. |
OS_AUTOMATION_SCHEDULED_WORK_ENABLED | false | Whether 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_BACKFILL | unset | Set to 1 to skip the boot-time membership backfill. |
See Environment variables for the full list.
Backup & Restore
What state an ObjectStack deployment actually holds, how to back up each piece per database driver, and the restore drill to rehearse before you need it.
Seed Tenancy Repair
The automatic repair that stamps organization_id on untenanted seed rows — when it runs, what it changes, and the manual fix for multi-org installs.