|
| 1 | +# ADR-0133: Organization management — the open basics; a mirror of cloud ADR-0081 D1/D3/D4 in the repository whose code enforces them |
| 2 | + |
| 3 | +- **Status**: Proposed (2026-09-06) — awaiting the maintainer's hand-merge, which is the acceptance act for a governed surface (Prime Directive #14). ⛔ Nothing in this file is a new decision; see [Provenance](#provenance--read-this-before-citing-this-file). |
| 4 | +- **Mirrors**: `objectstack-ai/cloud` **ADR-0081** (Status: Accepted, founder-decided in session, 2026-07-09) — **D1, D3 and D4 only**, the half whose mechanism is open code in this repository. |
| 5 | +- **Deliberately not mirrored**: cloud ADR-0081 **D2**, its non-goals, and its commercial consequences — cited as `cloud ADR-0081`, never restated here. ⚠️ D2 is absent for a stronger reason than the other two: it has been **reversed** by [ADR-0132](./0132-multi-organization-runtime-is-open-core.md), and the multi-organization machinery it packaged now ships in this repository (`packages/plugins/organizations`). What stays in cloud is that *record* — together with the non-goals and the commercial consequences, which the reversal leaves untouched. See [Relationship to the D2 reversal](#relationship-to-the-d2-reversal-adr-0132). |
| 6 | +- **Cross-checked against** (cite, do not duplicate): [ADR-0093](./0093-tenancy-mode-and-membership-lifecycle.md) (tenancy mode; the membership lifecycle; D9 already anchors the active-organization resolution), [ADR-0105](./0105-group-tenancy-posture-and-first-class-org-scope.md) (group tenancy posture; D12 anchors the multi-org entitlement), [ADR-0131](./0131-total-organization-ownership-no-null-organization-id.md) (total organization ownership; D1/D7/D9) |
| 7 | +- **Filed by**: [#14508](https://github.com/objectstack-ai/objectstack/issues/14508), a sub-issue of [#14496](https://github.com/objectstack-ai/objectstack/issues/14496) (maintainer ruling 2026-09-02, option 2: mirror the open half, do not move files) |
| 8 | +- **Consumers**: `@objectstack/platform-objects` (the identity surface and the Setup navigation contributions), `@objectstack/plugin-auth` (better-auth's organization plugin and the default-organization bootstrap), `@objectstack/spec` (the public auth-feature roster), and the objectui console that renders the surface |
| 9 | + |
| 10 | +--- |
| 11 | + |
| 12 | +## Provenance — read this before citing this file |
| 13 | + |
| 14 | +**The decisions restated below were taken in the sibling `objectstack-ai/cloud` |
| 15 | +repository, as cloud ADR-0081, on 2026-07-09.** They were founder-decided in |
| 16 | +session and accepted there. This file decides nothing. It is a mirror, written |
| 17 | +on 2026-09-06 from the cloud record plus the code in this repository that |
| 18 | +implements it. |
| 19 | + |
| 20 | +Three facts make that disclosure load-bearing rather than decorative: |
| 21 | + |
| 22 | +1. **Only the mechanism half is here.** Cloud ADR-0081 covers both what the |
| 23 | + platform does and what a subscription buys. A decision about commercial |
| 24 | + packaging belongs in the commercial repository; a decision about open code |
| 25 | + belongs where the code is (Prime Directive #13). This file therefore carries |
| 26 | + **D1, D3 and D4** and stops. Where this document and the cloud record |
| 27 | + disagree, **the cloud record is the decision and this file is the bug** — |
| 28 | + file an issue and this file gets corrected. |
| 29 | + |
| 30 | +2. ⚠️ **`ADR-0081` written bare resolves to the wrong document in this |
| 31 | + repository.** This repo's [ADR-0081](./0081-trusted-react-page-tier.md) is |
| 32 | + the trusted `kind:'react'` page tier, an unrelated record whose Decision |
| 33 | + section is not even D-numbered. Every reference to the organization- |
| 34 | + management record must be spelled `cloud ADR-0081`. This is the third local |
| 35 | + record to say so — ADR-0093 D9 and ADR-0105's citation note say it for the |
| 36 | + labels `ADR-0081 D1` and `ADR-0081 D2` respectively — and it is repeated |
| 37 | + here because this file is the one a reader following those citations now |
| 38 | + lands on. |
| 39 | + |
| 40 | +3. **The code still carries the pre-repo labels.** Several files in |
| 41 | + `packages/platform-objects` and `packages/plugins/plugin-auth` cite |
| 42 | + `ADR-0081 D1` in comments today. Re-pointing those citations is |
| 43 | + [#14361](https://github.com/objectstack-ai/objectstack/issues/14361)'s work |
| 44 | + and is ⛔ **not** done by the change that lands this file — so until that |
| 45 | + card lands, a reader may arrive here from a citation that names a different |
| 46 | + number. |
| 47 | + |
| 48 | +**What is restated vs. what is measured.** Every clause under |
| 49 | +[Decision](#decision) is either a restatement of the cloud record or a fact |
| 50 | +measured on this repository's `origin/main` at `77781151d` and cited by symbol |
| 51 | +anchor. Where the two disagree, or where the cloud record is silent about |
| 52 | +something this repo's code decides, it is recorded under |
| 53 | +[What this record does not decide](#what-this-record-does-not-decide) rather |
| 54 | +than resolved. |
| 55 | + |
| 56 | +--- |
| 57 | + |
| 58 | +## Relationship to the D2 reversal (ADR-0132) |
| 59 | + |
| 60 | +**A separate local record — "The multi-organization runtime is open core" — |
| 61 | +reverses cloud ADR-0081 D2.** It is |
| 62 | +[ADR-0132](./0132-multi-organization-runtime-is-open-core.md), and it is **on |
| 63 | +`main`** (landed 2026-09-07): a record, not a proposal. |
| 64 | + |
| 65 | +⛔ **It is cited here by ADR number and relative link, and nothing about that |
| 66 | +reversal is re-derived, restated or evaluated below.** This file previously |
| 67 | +cited it by PR number, which was a gate requirement rather than a style choice: |
| 68 | +`pnpm check:adr-anchors` fails any citation of an ADR number that names no |
| 69 | +record under `docs/adr/`, so while the record was unmerged its number was a |
| 70 | +squat and a citation of it would have been retroactively falsified had it landed |
| 71 | +under a different number. That constraint is discharged — the number resolves, |
| 72 | +so it is the citable form and the PR reference is retired from this file. |
| 73 | + |
| 74 | +The division of labour between the two records is exact and neither restates |
| 75 | +the other: |
| 76 | + |
| 77 | +| cloud ADR-0081 | who carries it locally | |
| 78 | +|---|---| |
| 79 | +| **D1** — the "add a teammate" basics stay open | **this record** | |
| 80 | +| **D2** — the multi-organization machinery is enterprise | **reversed** by [ADR-0132](./0132-multi-organization-runtime-is-open-core.md), which is on `main`. ⛔ Not re-derived, restated or evaluated here | |
| 81 | +| **D3** — the in-shell organization surface | **this record** | |
| 82 | +| **D4** — org-scoped roster reads | **this record** | |
| 83 | +| the non-goals and the commercial consequences | stay in cloud, cited as `cloud ADR-0081` | |
| 84 | + |
| 85 | +⚠️ **A note for the hand-merge, stated rather than decided.** ADR-0132 says in |
| 86 | +its own "What this record does not decide" section that D1/D3/D4 "are mirrored |
| 87 | +into this repository by #14508", and asks that writer to cite its decisions |
| 88 | +rather than open a *competing* record — which this file does: it decides nothing |
| 89 | +that record decides. A later comment on #14508 goes further and proposes folding |
| 90 | +D1/D3/D4 into that file instead of writing a second one. When this file was |
| 91 | +drafted that option was not available: ADR-0132 did not exist on `main`, so |
| 92 | +"extending" it would have meant building on an unmerged governed PR and coupling |
| 93 | +two hand-merges into one. That obstacle is gone — the record has landed — so the |
| 94 | +choice now stands on its merits alone. ⚠️ One of those merits is structural and |
| 95 | +survived the merge: that record carries **its own D1 through D6**, which are |
| 96 | +different decisions from cloud ADR-0081's D1/D3/D4, so folding this file into it |
| 97 | +would put two unrelated `D1`s in one document — the property the unrenumbered |
| 98 | +mirror above exists to keep checkable. This change takes the option neither way. |
| 99 | +**If the maintainer prefers one combined record, that is still a merge-time |
| 100 | +call** — the two files are disjoint, so folding them is a move, not a rewrite. |
| 101 | + |
| 102 | +--- |
| 103 | + |
| 104 | +## Context |
| 105 | + |
| 106 | +The open framework has always shipped member-management basics: better-auth's |
| 107 | +organization plugin mounted in `plugin-auth`, the organization and invitation |
| 108 | +objects in `platform-objects`, and the Setup surface over them. What was |
| 109 | +enterprise was the *multi-organization runtime*, not the ability to add a |
| 110 | +colleague to the organization you already have. |
| 111 | + |
| 112 | +That boundary is easy to get wrong in exactly one direction — quietly treating |
| 113 | +"organization" as an enterprise word and gating a basic affordance on the |
| 114 | +multi-org service. Cloud ADR-0081 D1 is the decision that forbids it, and the |
| 115 | +code says so at each gate: the invite affordance is gated on the org |
| 116 | +**capability**, never on multi-org. This record exists so that gate has a local |
| 117 | +number to cite. |
| 118 | + |
| 119 | +--- |
| 120 | + |
| 121 | +## Decision |
| 122 | + |
| 123 | +⛔ **Nothing here is decided by this file.** The D-numbers are cloud ADR-0081's |
| 124 | +own, deliberately **not** renumbered: a mirror that renumbers is a mirror a |
| 125 | +reader cannot check against its original. D2 is absent for the reason given |
| 126 | +above, so the sequence reads D1, D3, D4. |
| 127 | + |
| 128 | +### D1 — Adding a teammate is open, and always goes through better-auth invitations |
| 129 | + |
| 130 | +Basic member management is not an entitlement. The better-auth organization |
| 131 | +plugin is mounted unconditionally |
| 132 | +(`packages/plugins/plugin-auth/src/auth-schema-config.ts#buildOrganizationPluginSchema`), |
| 133 | +and single-organization deployments get a Default Organization from the |
| 134 | +bootstrap helper |
| 135 | +(`packages/plugins/plugin-auth/src/ensure-default-organization.ts#ensureDefaultOrganization`, |
| 136 | +`#isDefaultOrganizationBootstrapTrigger`) so that the endpoint's active-organization |
| 137 | +resolution has something to resolve. |
| 138 | + |
| 139 | +Two consequences this repository enforces: |
| 140 | + |
| 141 | +- **The gate is the organization capability, never multi-org.** The invite |
| 142 | + action declares `requiresFeature: 'organization'` |
| 143 | + (`packages/platform-objects/src/identity/sys-user.object.ts#requiresFeature`), |
| 144 | + and the spec's public roster keeps the two features apart: the member-management |
| 145 | + inputs sit under the organization capability while organization *lifecycle* |
| 146 | + actions sit under `packages/spec/src/kernel/public-auth-features.ts#multiOrgEnabled` |
| 147 | + (`#gatedInputs`). ADR-0093 D8 already records that this split is deliberate; |
| 148 | + it is cited, not re-argued. |
| 149 | +- **Every add flows through an invitation.** There is no bespoke user-CRUD path |
| 150 | + into membership. `invite_user` targets better-auth's invite-member endpoint and |
| 151 | + is declared on three objects, so it is reachable from wherever an admin |
| 152 | + happens to be looking: |
| 153 | + `packages/platform-objects/src/identity/sys-user.object.ts#invite_user`, |
| 154 | + `packages/platform-objects/src/identity/sys-invitation.object.ts#invite_user`, |
| 155 | + and `packages/platform-objects/src/identity/sys-member.object.ts#invite_user`. |
| 156 | + The three copies are held equal to each other — not to hand-copied literals — |
| 157 | + by `packages/platform-objects/src/identity/invite-entry-toolbar.test.ts#INVITE_ENDPOINT` |
| 158 | + and `#OBJECTS_BY_NAME`. |
| 159 | + |
| 160 | +⚠️ Membership rows are **read-only over the API**: `sys_member` declares |
| 161 | +`packages/platform-objects/src/identity/sys-member.object.ts#apiMethods` as reads |
| 162 | +only, and writes are owned by better-auth behind the identity write guard |
| 163 | +(ADR-0092 D2). "Open" here means *the affordance is not gated*, not *the table |
| 164 | +is writable*. |
| 165 | + |
| 166 | +### D3 — The in-shell surface is the organization RECORD page, reached by a templated nav deep-link |
| 167 | + |
| 168 | +What **this repository declares** is a navigation contribution: an `object`-typed |
| 169 | +Setup entry naming `sys_organization` with a templated `recordId` of |
| 170 | +`{current_org_id}`, in the People & Org group |
| 171 | +(`packages/platform-objects/src/apps/setup-nav.contributions.ts#nav_organization`). |
| 172 | +Its siblings — `#nav_teams`, `#nav_invitations` — carry **no** service gate, while |
| 173 | +the organization *list* keeps one (`#nav_organizations`, `requiresService: 'org-scoping'`): |
| 174 | +browsing organizations is meaningful only when more than one can exist. That |
| 175 | +asymmetry is D1 expressed in navigation. |
| 176 | + |
| 177 | +**The rendering half is objectui's, and this repository does not enforce it.** |
| 178 | +The console pinned by `.objectui-sha` substitutes the token and, when it cannot |
| 179 | +resolve, falls through to the list view rather than emitting a dead link |
| 180 | +(`objectui:packages/layout/src/NavigationRenderer.tsx#applyNavTemplate`, |
| 181 | +`#NavTemplateContext`, `#resolveHref`); related-list toolbars are bridged in |
| 182 | +declaration order by |
| 183 | +`objectui:packages/app-shell/src/views/RelatedRecordActionsBridge.tsx#deriveActions`. |
| 184 | +Those are objectui's behaviours, cited here so a reader can find them — ⛔ this |
| 185 | +record does not make them contracts of this repository, and a change to them is |
| 186 | +not a violation of this record. |
| 187 | + |
| 188 | +⚠️ **The tab set and its ordering are NOT declared by this repository, and this |
| 189 | +record does not decide them.** See |
| 190 | +[What this record does not decide](#what-this-record-does-not-decide). |
| 191 | + |
| 192 | +### D4 — Control-plane roster reads are organization-scoped |
| 193 | + |
| 194 | +`sys_member` is organization-scoped: it carries |
| 195 | +`packages/platform-objects/src/identity/sys-member.object.ts#organization_id` as a |
| 196 | +lookup to `sys_organization`, is uniquely indexed on the organization/user pair |
| 197 | +(`#indexes`), and pairs that column with `#user_id` and `#role`. The |
| 198 | +organization-capability gate on the identity surface is the same one D1 names. |
| 199 | + |
| 200 | +⛔ **This record does not restate what "org-scoped" now means — ADR-0131 owns it.** |
| 201 | +Since cloud ADR-0081 was accepted, [ADR-0131](./0131-total-organization-ownership-no-null-organization-id.md) |
| 202 | +(merged 2026-09-04) decided organization ownership for every row in the system, |
| 203 | +and it supersedes any reading of D4 as an opt-in property: |
| 204 | + |
| 205 | +- **D1/D9** — a row with an organization column was written by an organization, |
| 206 | + the column is `NOT NULL`, and a missing stamp is a refused write in every |
| 207 | + posture. Org-scoping is the default; the exception is a table with **no** |
| 208 | + column. |
| 209 | +- **D7** — whether a given object *keeps* the column at all is a **writer-facts** |
| 210 | + question decided per object by the C7 inventory, not by the object's name. |
| 211 | + |
| 212 | +⚠️ Consequently, **whether `sys_member` keeps its column is not decided here.** |
| 213 | +Today the field is declared optional and its own comment describes a null |
| 214 | +organization under single-tenancy — a reading ADR-0131 D1 does not permit going |
| 215 | +forward. That reconciliation belongs to ADR-0131's C6 census |
| 216 | +([#15207](https://github.com/objectstack-ai/objectstack/issues/15207)), and this |
| 217 | +record cites it rather than pre-empting it, exactly as it cites ADR-0093 rather |
| 218 | +than restating the membership lifecycle. |
| 219 | + |
| 220 | +--- |
| 221 | + |
| 222 | +## What this record does not decide |
| 223 | + |
| 224 | +This section is the honest residue: places where the cloud record is silent, or |
| 225 | +where this repository's code and its own comments do not agree. ⛔ None of it is |
| 226 | +resolved here. |
| 227 | + |
| 228 | +1. ⭐ **"Opens on tab-0 Members" is asserted in this repository's comments but |
| 229 | + declared by none of its metadata.** Two source comments and a QA checklist |
| 230 | + item describe the organization record page as opening on a Members tab with |
| 231 | + Invitations and Teams beside it. Measured on `origin/main` at `77781151d`, |
| 232 | + **no object in `packages/platform-objects/src/identity/` declares the |
| 233 | + `relatedList` prominence key** (`packages/spec/src/data/field.zod.ts#relatedList`) |
| 234 | + — the key objectui reads to promote a child list to its own tab — and no |
| 235 | + `relatedLayout` override exists anywhere in this repository. Under the |
| 236 | + documented default, with no primary list declared, related lists collapse |
| 237 | + into a single stacked tab. So the tab ordering is either an emergent property |
| 238 | + of the renderer or a claim that has gone stale; **this record states the |
| 239 | + deep-link contract, which is declared, and does not assert a tab order, which |
| 240 | + is not.** Filed separately rather than repaired here. |
| 241 | + |
| 242 | +2. **Whether `sys_member` keeps `organization_id`** — ADR-0131 D7's writer-facts |
| 243 | + question, answered by the C6 census (#15207). See D4. |
| 244 | + |
| 245 | +3. **The reconciliation of the optional column with ADR-0131 D1.** The field is |
| 246 | + declared optional today and ADR-0131 D1 says no nullable tenant column exists |
| 247 | + anywhere. That is a migration question on ADR-0131's own v18 line (its D10 |
| 248 | + and D14), not a mirror's to settle. |
| 249 | + |
| 250 | +4. **What an enterprise subscription buys, and where the multi-organization |
| 251 | + machinery lives.** Cloud ADR-0081 D2 and |
| 252 | + [ADR-0132](./0132-multi-organization-runtime-is-open-core.md), respectively. |
| 253 | + ⛔ Not evaluated here. |
| 254 | + |
| 255 | +--- |
| 256 | + |
| 257 | +## Consequences |
| 258 | + |
| 259 | +**Good.** The three local records that already carry pieces of this decision — |
| 260 | +ADR-0093 D9, ADR-0105 D12, ADR-0131 D1/D7/D9 — stop being the only local anchors |
| 261 | +for a decision none of them owns. A reader who follows an `ADR-0081 D1` citation |
| 262 | +out of the identity surface can now be sent somewhere that answers, which is the |
| 263 | +precondition for #14361's re-pointing work. |
| 264 | + |
| 265 | +**Costs, stated plainly.** One decision is now recorded in two repositories, and |
| 266 | +mirrors drift: this file has to be corrected when cloud ADR-0081 changes, and |
| 267 | +nothing mechanical will notice. The mitigation is the Provenance section's |
| 268 | +precedence rule (the cloud record decides) rather than a gate. The cloud record |
| 269 | +also owes a one-line pointer back to this number; that edit belongs to the cloud |
| 270 | +repository and is not made by the change that lands this file. |
| 271 | + |
| 272 | +**Reversibility.** Total — this file decides nothing, so deleting it costs only |
| 273 | +the citations that point at it. |
0 commit comments