Status: Accepted (2026-07-27; proposed 2026-07-25) — Phase 0/1 implemented (#3559). Amended 2026-07-27: D12 correction — group posture activation is entitled, not open (#3570; see the D12 Amendment)
Deciders: ObjectStack Protocol Architects
Builds on: ADR-0049 (enforce-or-remove), ADR-0057 (business units + scope depth), ADR-0066 (unified authz, superuser bypass), ADR-0086, ADR-0090 (permission set / position / business unit), ADR-0091 (validity windows), ADR-0092 (identity write guard + field whitelist), ADR-0093 (tenancy service), ADR-0095 (tenant Layer 0, posture ladder), ADR-0103; cloud ADR-0016 (open/paid boundary: 强制免费、治理收费), cloud ADR-0081 (@objectstack/organizations)
Tracking: #3541 (P0 findings F1/F2 became #3539/#3540, closed by #3559); cloud-side tracking cloud #874
Consumers: @objectstack/plugin-security, @objectstack/core (resolve-authz-context), @objectstack/plugin-auth (tenancy service), @objectstack/plugin-sharing, @objectstack/plugin-approvals, @objectstack/lint, dogfood conformance suite, @objectstack/organizations (cloud)
Group-shaped customers (multi-plant manufacturing, multi-branch retail, holding structures) need one shared database, organization as the membership boundary, group-wide visibility, and cross-org workflows — a shape between today's two tenancy modes. Today that middle is not just unsupported; an attempt to configure it by hand trips two latent defects (F1, F2 below).
Mainstream ERP converged on one answer decades ago: organization scope is a parameter of the authorization grant, enforced by the engine — SAP authorization objects carry org-level fields and derive roles per plant; NetSuite roles carry subsidiary restrictions; Dataverse security roles bind to business units with per-privilege access depth; Oracle retired one-org-at-a-time responsibility switching in favor of MOAC union access. This ADR adopts that end state:
- D1 — Tenancy becomes a three-posture spectrum:
single | group | isolated.group= organizations share one database as data, wall enforced by the engine with union semantics;isolated= today'smulti. - D2 —
accessible_org_idsbecomes a first-class, core-resolvedExecutionContextfield; thegroupLayer 0 filter isorganization_id IN accessible_org_ids(MOAC semantics), preserving ADR-0095's W1/W2 invariants (AND-first, independent compiler, no bypass bit). - D3 (P0, ships regardless) — stop stripping authored RLS policies: the
current_user.organization_idsubstring strip incollectRLSPoliciesis narrowed to the platform's own wildcard tenant policies. - D4 (P0, ships regardless) — contain
viewAllRecords/modifyAllRecords: the superuser bypass is bounded by the caller's org access set in every posture; wall-less postures never auto-grant unbounded wildcard VAMA. - D5–D13 — group-mode stamping, org grouping metadata (reporting-only, no permission inheritance), managed-table extension fields via the ADR-0092 whitelist, scoped invitations, cross-org approval targeting, layered master data, enforce-or-remove cleanup of dead spec surface, the open/commercial split, and a BU-subtree → organization promotion path so deployments can move along the spectrum without a rebuild.
Rejected: org-per-plant hard walls as the default group topology; config-only soft isolation on the current engine; a cross-org BU mega-tree; permission inheritance along an organization hierarchy. See Alternatives.
A manufacturing group runs N plants on one MES: each plant manages its own members and process configuration; group HQ approves plant documents and reads all plants' data. Two requirements are inherent to the shape, not optional:
- Group-wide visibility — consolidated reporting and drill-down across every plant.
- Cross-org workflow — plant-submitted documents route to group-level approvers.
single(ADR-0093): one logical tenant. Layer 0 inert (plugin-security/src/tenant-layer.ts:87). Factories can be modeled as business units in one tree — group visibility and approvals are native, but there is no per-factory membership/invitation boundary, and org scoping relies entirely on authored configuration.multi/ isolated (ADR-0093, ADR-0095): the hard wall.organization_id = activeOrganizationIdAND-composed first; no business-RLS change, sharing rule, or superuser bit can widen it (W1/W2). Correct for legal-entity isolation — and structurally hostile to the two inherent requirements above: one active org per session (core/src/security/resolve-authz-context.ts:125), org-scoped approver resolution (plugin-approvals/src/approval-node.ts:118, other-tenant position holders excluded —approval-service.test.ts:263-265), no cross-org query path short ofisSystemmirroring.
The middle — organizations as membership boundaries over one shared dataset —
is what group customers actually want. Attempting it today by hand (better-auth
orgs + organization_id columns + authored RLS, without
@objectstack/organizations) fails on two latent defects:
- F1 — authored org policies are silently stripped.
collectRLSPolicies(plugin-security/src/security-plugin.ts:2785-2791) drops any policy whoseusingcontains the substringcurrent_user.organization_idwhenever theorg-scopingservice is absent. The intent was to strip the platform's own wildcard tenant policies in single-tenant deployments; the substring match also swallows app-authored policies. A silently-dropped authored security policy is exactly the ADR-0049 class of defect ("declared but unenforced"). - F2 — wildcard VAMA is contained only by the wall.
organization_admincarries'*': { viewAllRecords: true, modifyAllRecords: true }(plugin-security/src/objects/default-permission-sets.ts:150-160) and is auto-granted to every owner/admin member (auto-org-admin-grant.ts). The superuser bit short-circuits Layer 1 business RLS and sharing; in the isolated posture Layer 0 contains it (the #2937 fix), but in any wall-less posture nothing does. Combined with automatic personal-org creation on signup, a wall-less multi-org deployment makes effectively every user an environment-wide superuser while operating in an org where they hold owner/admin. - F3 — dead spec surface on the org axis.
PermissionSet.contextVariables(spec/src/security/permission.zod.ts:282) has zero runtime consumers;ExecutionContext.rlsMembership(spec/src/kernel/execution-context.zod.ts:165, merged atplugin-security/src/rls-compiler.ts:164-177) has no production resolver — onlyorg_user_idsis populated;spec/src/security/territory.zod.tshas no runtime object or stack field. All three violate ADR-0049 enforce-or-remove. - F4 — the extension-field channel exists but has one whitelist entry.
Extending better-auth-managed tables is established practice
(
sys_user.manager_id,sys_user.primary_business_unit_id,sys_user.ai_access) and ADR-0092's registry-driven identity write guard with a per-object update whitelist is the sanctioned mechanism (sys_user → {name, image}is its first entry). Group-mode org/member metadata needs whitelist entries and a collision guard, not a new mechanism.
| System | Mechanism | Lesson adopted here |
|---|---|---|
| SAP (ECC/S4) | Authorization objects carry org-level fields (BUKRS, WERKS); derived roles re-parameterize a master role per plant; one client holds the whole group |
Org scope is a grant parameter, engine-checked; legal entities/plants are data, not tenants |
| Oracle EBS → R12/Fusion | One-OU-per-responsibility switching was retired for MOAC (security profile lists many OUs, union access); Fusion data access sets | Union access beats context switching — D2's IN semantics |
| Microsoft Dataverse | Security roles bound to business units; per-privilege access depth (user/BU/parent-child/org) — the model ADR-0057 adopted | Depth axis stays org-internal; BU tree is the intra-org hierarchy |
| NetSuite OneWorld | One DB; subsidiary on every record; roles carry subsidiary restrictions natively |
The closest analog of the group posture — and its scoping is engine-native, never a per-app convention |
| Workday | Role-based security groups assigned on org nodes | Grant = capability × org node (sys_user_position.business_unit_id anchoring, ADR-0090 Addendum) |
| Odoo | company_id + record rules only (config-level soft isolation) |
The cautionary tale: a forgotten record rule = cross-company leak. Config-only scoping is not an acceptable product answer (F1/F2 are our version of it) |
No mainstream vendor ships "org scope as an app-level configuration convention" as its group story. The engine owns it everywhere.
D1 — Three tenancy postures: single | group | isolated.
The tenancy service (ADR-0093) gains a group mode. Semantics:
| Posture | Wall | Stamping | Intended shape |
|---|---|---|---|
single |
none (Layer 0 inert) | none | one logical tenant; factories as BUs in one tree remain a fully supported deployment (Appendix A) |
group |
organization_id IN accessible_org_ids — engine-enforced, union |
engine stamps + validates on write | organizations = membership/invitation boundaries over one shared dataset |
isolated |
organization_id = activeOrganizationId (today's multi, ADR-0095 Layer 0) |
engine stamps active org | legal-entity / sovereignty isolation |
isolated behavior is unchanged. single behavior is unchanged except D3/D4
correctness fixes. Existing multi configuration maps to isolated.
D2 — accessible_org_ids: core-resolved union org scope.
resolveAuthzContext resolves the caller's full set of currently valid org
memberships (validity windows per ADR-0091, core/src/security/grant-validity.ts)
into a first-class ExecutionContext.accessible_org_ids, sibling to
org_user_ids. In group posture the Layer 0 filter becomes
{ organization_id: { $in: accessible_org_ids } }, composed exactly as
ADR-0095 D1 mandates: AND-first, independent compiler, no shared bypass bit
(W1/W2 preserved; only the predicate widens from equality to set membership).
Empty set ⇒ RLS_DENY_FILTER (fail closed), mirroring
tenant-layer.ts:103. The active organization keeps its current meaning
(default write target, UI context); it no longer bounds read reach in
group posture — membership does.
D3 (P0) — Strip only the platform's own wildcard tenant policies.
collectRLSPolicies stops substring-matching. The platform's wildcard
tenant_isolation policies are tagged (provenance flag on the default-set
policies, or object === '*' ∧ shipped-by-default), and only tagged policies
are stripped when isolation is inactive. An authored policy referencing an
unavailable context variable follows the existing availability path (fails
closed at resolution, surfaced by isSupportedRlsExpression authoring lint) —
it is never silently dropped. Ships in the open edition immediately;
independent of D1.
D4 (P0) — VAMA is bounded by org scope in every posture.
viewAllRecords / modifyAllRecords mean "bypass ownership, sharing, and
business RLS within the caller's org access set" — never "cross the org
boundary". Concretely: the Layer 1 superuser bypass no longer implies skipping
the org predicate in any posture where one applies (this is already true in
isolated per ADR-0095 W2; D4 extends the invariant to group), and
wall-less postures get a de-VAMA'd organization_admin variant from
auto-org-admin-grant so the F2 amplification path (personal orgs) closes.
Crossing org scope remains exclusively the PLATFORM_ADMIN rung on
posture-permitting objects (tenant-layer.ts:54,100), unchanged.
D5 — Group-mode stamping and write validation move into the engine.
On insert in group/isolated postures the engine stamps organization_id
from ctx.tenantId (active org) when absent, and validates any explicit
value against accessible_org_ids (group) / equality (isolated) — the
write-side twin of the read wall, alongside the existing Layer-0 post-image
check (#2937). tenancy.enabled: false objects
(spec/src/data/object.zod.ts:240-256) remain exempt: platform-global by
declaration, business RLS as their only scoping — the sanctioned escape hatch
for cross-org catalogs.
D6 — Organization grouping is reporting metadata; hierarchy stays on the BU
tree; the cut-line doctrine.
Group structure above the organization boundary (region, brand, legal
grouping, ordering) is carried by extension fields on sys_organization
(via D7), optionally including a parent_organization_id reference — as a
reporting/grouping dimension only. Two red lines, both lint-enforced:
- No permission inheritance along the org axis. Cross-org visibility comes from membership union (D2), never from walking an org tree. Re-adding a second permission hierarchy is the mistake ADR-0057 D5 retired and ADR-0090 D3 finalized for positions; it stays retired for organizations.
- BU trees remain org-internal.
sys_business_unitis org-scoped (plugin-sharing/src/business-unit-graph.ts:174-177; the enterprise resolver org-predicates all lookups). Every BU mechanism —unit_and_subordinatessharing,adminScopedelegation, depth scopes — operates within one organization. There is no cross-org tree.
The cut-line doctrine (deployment guidance, Appendix A): model the group's
full structure as one conceptual tree, then cut it at the layer that needs
membership autonomy and isolation. Above the cut → organizations (+ grouping
metadata). Below the cut → each org's BU tree. single posture = no cut.
D7 — Managed-table extension fields ride ADR-0092's whitelist.
Protocol fields (better-auth's own: email, slug, sys_member.role, …)
remain guarded exactly as ADR-0092 D2 prescribes. Group-mode extension fields
— org grouping metadata (D6), per-membership attributes (employee number,
plant-local status on sys_member — the org-cardinality home, since
sys_user is global) — are added as whitelist entries in the single ADR-0092
whitelist module, editable through the generic path under normal FLS /
requiredPermissions. New: a collision lint — extension field names on
better-auth-managed objects must not collide with the better-auth schema
surface of the pinned version, so a library upgrade cannot silently change a
field's owner.
D8 — Scoped invitations.
An invitation may carry placement intent: target business unit and positions.
On accept, the platform applies the placement (BU membership + position
assignments) atomically with the better-auth membership. Issuance is governed
by adminScope (ADR-0090 D12): a delegated admin may only invite into their
subtree and only attach allowlisted permission sets — the existing
anti-escalation gate, reused verbatim. This closes the one structural gap of
the single-posture deployment (plant-autonomous member admission) and is the
natural admission UX in group posture.
D9 — Cross-org approval targeting.
An approval chain node may name a target organization for approver
resolution (default: the request's org, today's behavior at
approval-node.ts:118). A plant document's escalation step declares
organization: <group org> and resolves group-side position holders. Reads of
the request by those approvers are covered by D2 (membership union) in group
posture; in isolated posture this decision does not apply (cross-org
approval there remains mirroring via system context, cloud #2937 contract).
D10 — Layered master data (group template + org override). A spec-level pattern for the SAP material-master / 用友-金蝶 distribution shape: an object may declare layered governance — group-level template rows (platform-global or group-org-owned, read-shared) plus per-org override rows linked to the template, with a resolution rule (org override wins). Mechanics (linkage field, resolution in the read path or a view layer, distribution policies: 集团统管 / 分级 / 自由) to be detailed in a follow-up ADR; this ADR reserves the concept and its place in Phase 2.
D11 — Enforce-or-remove cleanup (ADR-0049 debts on the org axis).
PermissionSet.contextVariables: remove from the spec (no consumer; its use cases are covered byrlsMembershipand literal predicates).ExecutionContext.rlsMembership: productize the seam — a registered membership-resolver extension point (service contract) that plugins/apps implement; resolved sets merge under their declared keys (rls-compiler.ts:164-177already merges).accessible_org_ids(D2) is core-resolved, not an app resolver.spec/src/security/territory.zod.ts: remove. Matrix requirements are served today by multi-position × BU anchoring; a generalized dimension security module (Workday segments / Dataverse XDS class) may revisit later with its own ADR.
D12 — Edition split, per the cloud ADR-0016 iron rule (强制免费、治理收费).
(As amended 2026-07-27, #3570 — see the Amendment below for the original
text and why it was wrong.) The split is code vs. activation, not code
vs. code. The wall's implementation ships open — D3/D4 correctness, the
Layer 0 predicates, D5 stamping/validation, accessible_org_ids resolution,
the D6 red-line lints — exactly as isolated's wall has always lived in
plugin-security. Posture activation is entitled: group probes the
enterprise org-scoping runtime (@objectstack/organizations) exactly like
isolated; without it the tenancy service resolves the posture to single +
degraded, and an os serve boot configured for group refuses to
start (the ADR-0093 D5 guard, keyed off the resolved posture) rather than
silently running unwalled. The iron rule guarantees a deployment running a
multi-org shape is safe by default; that is satisfied by refusing to run one
unwalled — open code is not free activation. ADR-0081 D2's commercial
line stands: both multi-org postures are @objectstack/organizations
capability. Commercial surface (unchanged): org lifecycle management,
grouping/registry UI, scoped invitations UX, cross-org approval templates,
master-data distribution management, per-org seed/config replay, org
analytics, and the D13 promotion tooling.
Amendment (2026-07-27, #3570). As proposed, this section read "the
groupwall ships open — an open deployment configured into the group shape must be safe by default; the wall's correctness is never paid", and #3559 implemented that reading: a self-activatinggroupposture with no entitlement probe. That was the founder decision flagged in #3559, and the founder ruled it wrong on review. Two defects in the original reading: it made the stronger multi-org posture (union wall) free while the weaker one (isolated) stayed entitled — inverting ADR-0081 D2 and handing out a free path around theorg-scopinggate — and it opened a silent-degradation hole (os servegated the enterprise package load onOS_MULTI_ORG_ENABLED, soOS_TENANCY_POSTURE=groupskipped both the load and the ADR-0093 D5 fail-fast, booting single-org without saying so). The iron rule's obligation is a security property (never run a multi-org shape unwalled), not a packaging one (give the posture away). #3570 restored the entitled model above; safety stays free because an unwalledgroupboot is refused, not run.
D13 — Migration along the spectrum; BU-subtree → organization promotion.
Deployments move single → group → isolated without schema rework because
organization_id columns exist everywhere by construction. The load-bearing
tool: promote a BU subtree to an organization — create the org, move BU
subtree rows under it, backfill organization_id on scoped business data from
the subtree's scoping field, convert BU memberships to org memberships +
placements, re-home position assignments. This is "moving the cut line" (D6)
as a supported operation, and the exit path when one plant later requires
legal isolation (promote, then flip posture or extract to its own
environment).
- No change to the physically-isolated cloud topology (one database per
environment, ADR-0002/0095):
groupis an in-database posture. - No cross-org permission inheritance, org-axis role trees, or a second hierarchy (D6 red lines).
- No org-per-plant hard-wall default for group customers (see Alternatives).
- No change to
PLATFORM_ADMINwall-crossing semantics (ADR-0095 D3 /posturePermitsCrossTenantstays as-is). - D10 mechanics (linkage/resolution/distribution) — reserved, follow-up ADR.
- Phase 0 (correctness, immediately; open edition; independent of D1):
D3, D4, D11 removals (
contextVariables, territory), collision lint (D7), conformance-matrix rows for the wall-less-VAMA and authored-policy-survival cases. - Phase 1 (group posture MVP): D1, D2, D5, D6 (fields + lints), D7 whitelist entries, group-mode default permission sets. Acceptance: the three-plants-one-group dogfood — plant admins configure process data mutually invisibly, group reads all plants on one screen, zero custom security code.
- Phase 2 (group product depth): D8, D9, D10, "all my organizations" console affordances, D13 promotion tool.
- Phase 3 (governance, commercial): SoD constraints, certification campaigns over ADR-0091 validity/recert data, deny/muting layer (ADR-0066 ⑦⑧), generalized dimension security (territory's successor).
- Org-per-plant hard walls (isolated posture) as the group default.
Evaluated in depth for the MES shape. Both inherent requirements become
architecture problems: group visibility needs
isSystemmirror/rollup pipelines (cloud #2937 contract), approvals fragment per org (org-scoped approver resolution), every cross-org flow is bespoke integration. Retained only for genuine legal isolation — reachable via D13, never the default. - Config-only soft isolation on the current engine (better-auth orgs + authored RLS, no engine wall). Defeated by F1 (policies stripped) and F2 (VAMA uncontained); even with both fixed, it reproduces the Odoo failure mode — every new object is one forgotten policy away from a cross-plant leak, with no engine invariant behind it. Rejected as a product answer; D1/D2 exist to make the engine own it.
- One cross-org BU mega-tree (group→plants→workshops in a group org).
Fights the grain of every org-scoped BU mechanism (sharing expansion,
adminScope, depth anchors — all org-predicated); plant users' active context would not even see the tree. Rejected; cut-line doctrine instead. - Permission inheritance along
parent_organization_id. Re-creates the dual-hierarchy mistake (ADR-0057 D5); visibility via membership union is strictly simpler and matches MOAC. Rejected; reporting dimension only. PLATFORM_ADMINas the group-HQ access path. Wall-crossing is an operator affordance gated on posture-permitting objects, bundled with metadata/settings capabilities — over-grant by construction for business analysts. Rejected; D2 membership union is the business path.
Positive: the tenancy story becomes a spectrum matching customer growth (single plant → group → legal split) with no data rework at the transitions; org scoping gains the same engine-invariant status the tenant wall has today (closing the F1/F2 class permanently); the platform's group story reaches parity with the NetSuite/SAP mechanism class while keeping the open-core boundary honest per cloud ADR-0016.
Negative / costs: a third posture multiplies the authz test matrix (every
conformance row × posture); accessible_org_ids adds a resolver read path
that must respect ADR-0091 validity windows and the existing 60s cache
staleness envelope; the D12 split is code-vs-activation — the wall's
implementation is open while both multi-org postures stay entitled (the
original open-activation concession shipped in #3559 and was reverted by
#3570, see the D12 Amendment); D13
promotion is a data migration with real blast radius and needs its own
verification harness.
Risks: D4 touches the superuser hot path — it lands behind the
authz-matrix-gate snapshot exactly as ADR-0095 did, any visibility delta
outside the two intended F1/F2 corrections is a bug; D2's union read wall
changes what "active organization" means to end users in group posture — UI
affordances (org switcher as write context + "all my orgs" read views) must
land with Phase 1, not after.
For a group MES engagement now, on the current platform:
- Posture:
single. One organization; the full structure as one BU tree (集团 → 工厂 → 车间 → 产线) — the SAP one-client shape, and after Phase 1 simply the no-cut point on the D6 spectrum. - Plant scoping: business objects carry a plant reference; high-volume
transactional tables scope via RLS predicates (per-plant permission sets
generated in code — the hand-rolled derived-role pattern) or, with the
enterprise hierarchy resolver,
readScope/writeScopedepth anchored viasys_user_position.business_unit_id. Criteria sharing rules (unit_and_subordinates) for low-volume collaboration only — they materializesys_record_sharerows per record. - Plant admin:
adminScopedelegation per plant subtree (ADR-0090 D12); duty delegation via ADR-0091 delegatable positions. - Group master data: group-standard objects OWD
public_readwith write permission held by group positions; plant-local overrides as plant-scoped rows (D10 formalizes this later). - Member admission: until D8 lands, plant-scoped invitation is an app-level flow (invitation carries BU/position intent; placement on accept).
- What not to do: do not hand-build wall-less multi-org on the current engine (Alternative 2 / F1 / F2), and do not put plants behind isolated-org walls to "prepare" for D1 — the D13 promotion path exists precisely so the cut line can move later.