Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion content/docs/permissions/sharing-rules.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ principal: **system** contexts, and **platform operators** — a holder of
`manage_platform_settings` or of the built-in `platform_admin` position
administers rules across the deployment whether or not an organization is
selected, which is also what a single-tenant deployment looks like before its
default organization is bootstrapped (ADR-0081 D1).
default organization is bootstrapped (cloud ADR-0081 D1).

### Switching a rule off withdraws the access it granted

Expand Down
22 changes: 14 additions & 8 deletions docs/adr/0093-tenancy-mode-and-membership-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
- **Date:** 2026-07-13
- **Deciders:** ObjectStack Protocol Architects
- **Implementation:** #2882 (Phase 0 — tactical create-user bind, merged) → this PR (Phases 1–3 — `tenancy` service, fail-fast boot guard, membership reconciler, consumer migration, backfill, docs). One revision from the original plan, ratified in D2: the endpoint-level create-user bind **delegates to the shared reconciler** (one implementation, two call sites) instead of being deleted. Runtime verification confirmed the hook fires for `admin.createUser`, but better-auth *defers* `user.create.after` post-commit (#1881), so the endpoint keeps its delegated call to report `organizationId` / `membershipCreated` deterministically in its response. Cloud-host semantics (personal-org hook precedence, multi-org non-binding, D5 blast radius) verified against `objectstack-ai/cloud` — see D2/D3/D5.
- **Relates to:** [ADR-0049](./0049-no-unenforced-security-properties.md) (no unenforced security properties), [ADR-0057](./0057-erp-authorization-core-business-units-and-scope-depth.md) (org-scoped identity optionality), [ADR-0068](./0068-unified-user-context-and-built-in-identity-roles.md) (platform-admin gate), [ADR-0092](./0092-sys-user-profile-field-delegation.md) (identity write guard), the default-org bootstrap (`plugin-auth/src/ensure-default-organization.ts`, referenced in code as "ADR-0081 D1" — that decision record predates this repo's ADR series), #2766 (admin user management), PR #2882 (single-org create-user membership bind — the tactical fix this ADR generalizes)
- **Relates to:** [ADR-0049](./0049-no-unenforced-security-properties.md) (no unenforced security properties), [ADR-0057](./0057-erp-authorization-core-business-units-and-scope-depth.md) (org-scoped identity optionality), [ADR-0068](./0068-unified-user-context-and-built-in-identity-roles.md) (platform-admin gate), [ADR-0092](./0092-sys-user-profile-field-delegation.md) (identity write guard), the default-org bootstrap (`plugin-auth/src/ensure-default-organization.ts`, referenced in code as "cloud ADR-0081 D1" — identified 2026-09-04: that record is **cloud ADR-0081**, *Organization Management — Open Basics, Enterprise `@objectstack/organizations`*, Accepted 2026-07-09 in the `objectstack-ai/cloud` repo's own ADR series, where its **D1** is this bootstrap; it predates *this* repo's series because it lives in that one, where it is current), #2766 (admin user management), PR #2882 (single-org create-user membership bind — the tactical fix this ADR generalizes)

## TL;DR

Expand Down Expand Up @@ -65,8 +65,9 @@ Decision:
resolves a session's `activeOrganizationId` from the caller's `sys_member`
row (owner-preferred, else oldest), only when the draft lacks one,
best-effort, opt-out via `autoActiveOrganization: false`. Shipped behaviour,
previously cited in code as a pre-repo "ADR-0081 D1" whose number now
collides with this repo's ADR-0081; anchored here because it reads exactly
previously cited in code as an unqualified "ADR-0081 D1" — the record is
cloud ADR-0081 (identified 2026-09-04), and written without that qualifier
the number collides with this repo's ADR-0081; anchored here because it reads exactly
the invariant D1 states and D2 owns.

## Context
Expand Down Expand Up @@ -384,7 +385,9 @@ admin surfaces.
different, larger decision — refused here to keep this ADR mechanical.
2. **The dual frontend flags keep their meaning.** `features.organization`
(member management available) vs `features.multiOrgEnabled` (org management
available) is a deliberate ADR-0081-D1 distinction; only their *backing
available) is a deliberate cloud ADR-0081 D1/D2 distinction — D1 keeps
basic member management OPEN, D2 closes multi-org into the enterprise
`@objectstack/organizations` package; only their *backing
fact* moves to the tenancy service.
3. **better-auth keeps owning `sys_member` CRUD.** The reconciler writes
through the system context exactly as `ensureDefaultOrganization` does; no
Expand All @@ -398,12 +401,15 @@ admin surfaces.
> **This decision is a RECORDING, not a new ruling.** The behaviour below has
> shipped since before this ADR, and nothing about it changes. What changes is
> that it now has an anchor. The code carried it as **"ADR-0081 D1"**, a label
> inherited from a decision record that predates this repo's ADR series (the
> same pre-repo record the *Relates to* line names for the default-org
> bootstrap). That number now collides with this repo's
> inherited from **cloud ADR-0081** — *Organization Management — Open Basics,
> Enterprise `@objectstack/organizations`*, Accepted 2026-07-09 in the
> `objectstack-ai/cloud` repo's ADR series (identified 2026-09-04; the same
> record the *Relates to* line names for the default-org bootstrap). Written
> without that qualifier the number collides with this repo's
> [ADR-0081](./0081-trusted-react-page-tier.md) — the trusted `kind:'react'`
> page tier — so a reader following the citation landed in a document about
> React pages with no signal they were in the wrong record. The decision is
> React pages with no signal they were in the wrong record; the surviving
> citations elsewhere in this repo now carry the `cloud ` qualifier. The decision is
> restated here because this is the record that owns the fact it depends on:
> `sys_member` and the membership lifecycle (D1/D2).

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -340,11 +340,15 @@ 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.

> **Citation note (2026-08-16) — hygiene, not a decision.** Code and tests
> carried this entitlement as **"ADR-0081 D2"**, a label inherited from a
> decision record that predates this repo's ADR series — the same pre-repo
> record whose "ADR-0081 D1" label [ADR-0093](./0093-tenancy-mode-and-membership-lifecycle.md)
> D9 names. That number now collides with this repo's
> **Citation note (2026-08-16; record identified 2026-09-04) — hygiene, not a
> decision.** Code and tests carried this entitlement as **"ADR-0081 D2"**, a
> label inherited from **cloud ADR-0081** — *Organization Management — Open
> Basics, Enterprise `@objectstack/organizations`*, Accepted 2026-07-09 in the
> `objectstack-ai/cloud` repo's ADR series, which is the record the *Builds on*
> line above already cites by that name, and the same one whose "ADR-0081 D1"
> label [ADR-0093](./0093-tenancy-mode-and-membership-lifecycle.md)
> D9 names; its **D2** is the multi-org entitlement decision. Written without
> that qualifier the number collides with this repo's
> [ADR-0081](./0081-trusted-react-page-tier.md), the trusted `kind:'react'`
> page tier, whose Decision section is numbered 1–4 and has no D-numbered
> decisions at all — so a reader following the citation landed in a document
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -825,9 +825,12 @@ stamp-and-backfill repair (#12929, #13180, #13527, #13572, #13565, #14726) and #
- The issue body of #13564 cites the arm by a bare line number (7320) that had drifted by ~4,600 lines
before the first census read it; on `origin/main` `2514d49f3` the
arms are inside `applyTenantScope` (~12016–12066) and there are **two**. This record cites symbols.
- `ensure-default-organization.ts` cites "ADR-0081 D1" for the Default Organization; in this
repository `docs/adr/0081` is the trusted React page tier — the "0081" is cloud's numbering. The
framework record is [ADR-0093](./0093-tenancy-mode-and-membership-lifecycle.md).
- `ensure-default-organization.ts` cites "cloud ADR-0081 D1" for the Default Organization; in this
repository `docs/adr/0081` is the trusted React page tier — the "0081" is cloud's numbering, and the
record is **cloud ADR-0081** (*Organization Management — Open Basics, Enterprise
`@objectstack/organizations`*, Accepted 2026-07-09), identified 2026-09-04, so the citations now carry
the qualifier rather than resolving here. The framework record is
[ADR-0093](./0093-tenancy-mode-and-membership-lifecycle.md).
- `resolve-authz-context.ts` already resolves positions **by name** (`{ name: { $in: grants.positions } }`
against `sys_position`); D4 changes where that lookup goes first, not what it is keyed on.
- `template-loader.ts` resolves by `(name, locale)`; a grep for `organization` / `tenant` in it returns
Expand Down
10 changes: 5 additions & 5 deletions docs/qa/platform-checklist/areas/identity-auth.json
Original file line number Diff line number Diff line change
Expand Up @@ -754,12 +754,12 @@
"fixtures": {
"app": "showcase",
"requires": [
"the organization capability mounted (always mounted per ADR-0081 D1; plugin-auth's default-org bootstrap guarantees an org to manage) and a session with an active organization so {current_org_id} resolves",
"the organization capability mounted (always mounted per cloud ADR-0081 D1; plugin-auth's default-org bootstrap guarantees an org to manage) and a session with an active organization so {current_org_id} resolves",
"at least two members in the org so role changes and removals have a target that is not the admin"
]
},
"steps": [
"sign in as the org owner/admin and open Setup → People & Org → Organization (nav_organization: type object, objectName sys_organization, recordId {current_org_id}, ADR-0081); screenshot the org record page and confirm {current_org_id} resolved to the session's active org (not the list fallback)",
"sign in as the org owner/admin and open Setup → People & Org → Organization (nav_organization: type object, objectName sys_organization, recordId {current_org_id}, cloud ADR-0081 D3); screenshot the org record page and confirm {current_org_id} resolved to the session's active org (not the list fallback)",
"confirm the record page exposes the Members / Invitations / Teams tabs with the better-auth row actions (GET list-members, list-invitations, list-teams feed them)",
"change a member's role: POST /api/v1/auth/organization/update-member-role (client organizations.updateMemberRole) to one of the ADR-0108 closed 4-name vocabulary {owner, admin, delegated_admin, member}; read the membership back and confirm the new role",
"prove the vocabulary is CLOSED, not merely conventional: attempt the same call with role 'guest' (and with any stack-declared position/permission-set name, e.g. showcase's 'contributor') — better-auth's role check refuses it (400 ROLE_NOT_FOUND) before any insert, and no membership/invitation row is left behind",
Expand All @@ -771,7 +771,7 @@
],
"acceptance": [
{
"clause": "{current_org_id} resolves to the session's active organization: the Organization nav opens that org's record page (Members/Invitations/Teams tabs), not the raw sys_organization list — the ADR-0081 active-org token is wired",
"clause": "{current_org_id} resolves to the session's active organization: the Organization nav opens that org's record page (Members/Invitations/Teams tabs), not the raw sys_organization list — the cloud ADR-0081 D3 active-org token is wired",
"oracle": "screenshot",
"verify": "the org record page renders for the active org id (cross-checked against GET /api/v1/auth/organization/get-active-member / get-full-organization); the three management tabs are present",
"evidence": "the org-page screenshot + the get-active/get-full response"
Expand Down Expand Up @@ -817,11 +817,11 @@
"an org management surface where the affordance is hidden but the forged endpoint succeeds for a non-admin is a FAIL — the server is the authority (ADR-0124 D1)",
"remove-member that drops the roster row but leaves the ex-member's org-scoped access intact is a FAIL — removal must change authorization",
"a role written outside the {owner, admin, delegated_admin, member} vocabulary is a FAIL — including a stored 'guest': the closed list is the write-side guardrail that makes an ungoverned capability grant unrepresentable (ADR-0108), so a 2xx that persists 'guest' is a regression of the closure, not a vocabulary difference. A role change that does not flip any gate is equally a FAIL",
"the Organization nav landing on the raw sys_organization list because {current_org_id} did not resolve (when an active org exists) is a FAIL of the ADR-0081 wiring"
"the Organization nav landing on the raw sys_organization list because {current_org_id} did not resolve (when an active org exists) is a FAIL of the cloud ADR-0081 D3 wiring"
],
"traps": ["wrong-persona", "dispatcher-vs-hono-route", "hydration-race"],
"source": [
"packages/platform-objects/src/apps/setup-nav.contributions.ts#nav_organization (nav_organization recordId {current_org_id}, ADR-0081; Teams/Invitations always mounted per ADR-0081 D1)",
"packages/platform-objects/src/apps/setup-nav.contributions.ts#nav_organization (nav_organization recordId {current_org_id}, cloud ADR-0081 D3; Teams/Invitations always mounted per cloud ADR-0081 D1)",
"packages/plugins/plugin-auth/src/auth-route-ledger.ts (organization family: update-member-role, remove-member, update, create-team, add-team-member, list-members/teams/invitations, get-active-member, get-full-organization)",
"packages/spec/src/identity/membership-role.ts#BUILTIN_MEMBERSHIP_ROLES (BUILTIN_MEMBERSHIP_ROLES / BUILTIN_MEMBERSHIP_ROLE_OPTIONS — THE role vocabulary: owner/admin/delegated_admin/member, ADR-0108; 'nothing widens these at boot any more')",
"docs/adr/0108-membership-grade-is-not-a-capability-channel.md (why the list is closed: a grade decides what you can REACH, never a bundle of what you may do)",
Expand Down
4 changes: 2 additions & 2 deletions packages/platform-objects/src/apps/setup-nav.contributions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,12 +71,12 @@ export const SETUP_NAV_CONTRIBUTIONS: NavigationContribution[] = [
{ id: 'nav_users', type: 'object', label: 'Users', objectName: 'sys_user', icon: 'user' },
// The ACTIVE organization's record page (Members / Invitations / Teams
// tabs with the better-auth row actions), rendered inside the app shell
// (ADR-0081). `{current_org_id}` resolves from the session's active
// (cloud ADR-0081 D3). `{current_org_id}` resolves from the session's active
// organization; unresolved (e.g. org-less admin before bootstrap) it
// falls back to the sys_organization list — one row in single-org.
{ id: 'nav_organization', type: 'object', label: 'Organization', objectName: 'sys_organization', recordId: '{current_org_id}', icon: 'building-2' },
{ id: 'nav_business_units', type: 'object', label: 'Business Units', objectName: 'sys_business_unit', icon: 'building', requiresObject: 'sys_business_unit' },
// Teams / Invitations no longer gate on `org-scoping` (ADR-0081 D1):
// Teams / Invitations no longer gate on `org-scoping` (cloud ADR-0081 D1):
// the better-auth organization capability is always mounted, and
// plugin-auth's single-org default-org bootstrap guarantees an org to
// invite into — these are the OPEN member-management basics. Only the
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// #11544 — the email-invite entry was UNREACHABLE from where admins actually
// look. The org record page (ADR-0081) opens on tab-0 **Members**
// look. The org record page (cloud ADR-0081 D3) opens on tab-0 **Members**
// (`sys_member`), whose toolbar carried exactly one action — `add_member`,
// which attaches an ALREADY-REGISTERED user by id. `invite_user` lived only on
// tab-1 Invitations. The maintainer, looking to "invite a teammate by email",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export const SysMember = ObjectSchema.create({
{
// THIRD mirror of `invite_user` (sys_user, sys_invitation are the other
// two — keep all three consistent). It is here because the org record
// page (ADR-0081) opens on tab-0 **Members**, and the email-invite entry
// page (cloud ADR-0081 D3) opens on tab-0 **Members**, and the email-invite entry
// used to live only on tab-1 Invitations: an admin looking to "invite a
// teammate by email" landed on Members, saw only "Add Member" (attach an
// existing user by id), and concluded the product had no invite entry.
Expand Down
2 changes: 1 addition & 1 deletion packages/platform-objects/src/identity/sys-user.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ export const SysUser = ObjectSchema.create({
locations: ['list_toolbar'],
type: 'api',
target: '/api/v1/auth/organization/invite-member',
// Gated on the org CAPABILITY, not multi-org (ADR-0081 D1): the
// Gated on the org CAPABILITY, not multi-org (cloud ADR-0081 D1): the
// better-auth organization plugin is always mounted, and single-org
// mode now bootstraps a Default Organization (plugin-auth) so the
// endpoint's active-org resolution works there too. This is THE
Expand Down
2 changes: 1 addition & 1 deletion packages/plugins/plugin-auth/src/auth-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1256,7 +1256,7 @@ describe('AuthPlugin', () => {
});
});

// ADR-0081 D1 — single-org default-organization bootstrap.
// cloud ADR-0081 D1 — single-org default-organization bootstrap.
describe('Single-org default-org bootstrap', () => {
const OLD_ENV = process.env.OS_MULTI_ORG_ENABLED;
let hookCapture: ReturnType<typeof createHookCapture>;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

// ADR-0081 D1 — the default-org bootstrap helper (open home: plugin-auth).
// cloud ADR-0081 D1 — the default-org bootstrap helper (open home: plugin-auth).
// Covers the idempotency short-circuits, the create/reuse paths, and the
// injectable seed-ownership step (enterprise injects it; open path omits it).

Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* ensureDefaultOrganization — default-org bootstrap helper (ADR-0081 D1).
* ensureDefaultOrganization — default-org bootstrap helper (cloud ADR-0081 D1).
*
* The platform admin needs at least one `sys_organization` so their sessions
* can carry an `activeOrganizationId`. Without it:
* - multi-org: the default `tenant_isolation` RLS policy filters everything
* to zero rows and the admin sees an empty console;
* - single-org: better-auth `organization/invite-member` has no active org
* to resolve, so there is NO way to add a user at all — the gap ADR-0081
* closes.
* to resolve, so there is NO way to add a user at all — the gap
* cloud ADR-0081 D1 closes.
*
* This helper HOME is plugin-auth (the open member-management basics). The
* enterprise organizations package reuses it for the multi-org bootstrap and
Expand Down Expand Up @@ -351,7 +351,7 @@ export async function ensureDefaultOrganization(
'[default-org] the Default Organization row was NOT created — the platform admin has no '
+ 'organization, so under multi-org the default tenant_isolation RLS policy filters their '
+ 'console to zero rows, and under single-org better-auth has no active org to resolve, so '
+ 'there is no way to add a user at all (ADR-0081 D1). NOTHING ELSE FAILS AND THE BOOT GOES '
+ 'there is no way to add a user at all (cloud ADR-0081 D1). NOTHING ELSE FAILS AND THE BOOT GOES '
+ 'ON LOOKING HEALTHY: this line is the only notice. Remedy: make the sys_organization '
+ 'insert land — check the write permission and driver connectivity, and whether a legacy '
+ 'unique index on `slug` is refusing `default`; the bootstrap re-runs on every '
Expand Down
2 changes: 1 addition & 1 deletion packages/spec/src/kernel/public-auth-features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ export const PUBLIC_AUTH_FEATURES = {
'sys_team_member.actions.add_team_member',
'sys_team_member.actions.remove_team_member',
],
notes: 'Org CAPABILITY gate, not multi-org (ADR-0081 D1).',
notes: 'Org CAPABILITY gate, not multi-org (cloud ADR-0081 D1).',
},
multiOrgEnabled: {
surface: 'crud',
Expand Down
Loading
Loading