|
| 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. Those govern a packaging choice, stay in cloud, and are cited as `cloud ADR-0081` — never restated here. See [Relationship to the D2 reversal](#relationship-to-the-d2-reversal-16215). |
| 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 (#16215) |
| 59 | + |
| 60 | +**A separate local record — "The multi-organization runtime is open core" — |
| 61 | +reverses cloud ADR-0081 D2.** It is open as a draft PR |
| 62 | +([#16215](https://github.com/objectstack-ai/objectstack/pull/16215)) and is not |
| 63 | +yet on `main`. |
| 64 | + |
| 65 | +⛔ **It is referenced here by PR number and never by its ADR number, and that is |
| 66 | +a gate requirement rather than a style choice.** `pnpm check:adr-anchors` fails |
| 67 | +any citation of an ADR number that names no record under `docs/adr/`: an |
| 68 | +unmerged number is a squat, and every citation of it is retroactively falsified |
| 69 | +if the record lands under a different number. A PR reference cannot rot that |
| 70 | +way. **When #16215 merges, its number becomes citable and this file should be |
| 71 | +updated to use it** — that edit is cheap, mechanical, and owed to whichever of |
| 72 | +the two records lands second. |
| 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 | the record proposed in **#16215**, which reverses it. ⛔ 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.** #16215's record |
| 86 | +says in its own "What this record does not decide" section that D1/D3/D4 "are |
| 87 | +mirrored into this repository by #14508", and asks that writer to cite its |
| 88 | +decisions rather than open a *competing* record — which this file does: it |
| 89 | +decides nothing that record decides. A later comment on #14508 goes further and |
| 90 | +proposes folding D1/D3/D4 into that file instead of writing a second one. That |
| 91 | +option was not available to this change: the file does not exist on `main`, so |
| 92 | +"extending" it would mean building on an unmerged governed PR and coupling two |
| 93 | +hand-merges into one. **If the maintainer prefers one combined record, that is a |
| 94 | +merge-time call** — the two files are disjoint, so folding them is a move, not a |
| 95 | +rewrite. |
| 96 | + |
| 97 | +--- |
| 98 | + |
| 99 | +## Context |
| 100 | + |
| 101 | +The open framework has always shipped member-management basics: better-auth's |
| 102 | +organization plugin mounted in `plugin-auth`, the organization and invitation |
| 103 | +objects in `platform-objects`, and the Setup surface over them. What was |
| 104 | +enterprise was the *multi-organization runtime*, not the ability to add a |
| 105 | +colleague to the organization you already have. |
| 106 | + |
| 107 | +That boundary is easy to get wrong in exactly one direction — quietly treating |
| 108 | +"organization" as an enterprise word and gating a basic affordance on the |
| 109 | +multi-org service. Cloud ADR-0081 D1 is the decision that forbids it, and the |
| 110 | +code says so at each gate: the invite affordance is gated on the org |
| 111 | +**capability**, never on multi-org. This record exists so that gate has a local |
| 112 | +number to cite. |
| 113 | + |
| 114 | +--- |
| 115 | + |
| 116 | +## Decision |
| 117 | + |
| 118 | +⛔ **Nothing here is decided by this file.** The D-numbers are cloud ADR-0081's |
| 119 | +own, deliberately **not** renumbered: a mirror that renumbers is a mirror a |
| 120 | +reader cannot check against its original. D2 is absent for the reason given |
| 121 | +above, so the sequence reads D1, D3, D4. |
| 122 | + |
| 123 | +### D1 — Adding a teammate is open, and always goes through better-auth invitations |
| 124 | + |
| 125 | +Basic member management is not an entitlement. The better-auth organization |
| 126 | +plugin is mounted unconditionally |
| 127 | +(`packages/plugins/plugin-auth/src/auth-schema-config.ts#buildOrganizationPluginSchema`), |
| 128 | +and single-organization deployments get a Default Organization from the |
| 129 | +bootstrap helper |
| 130 | +(`packages/plugins/plugin-auth/src/ensure-default-organization.ts#ensureDefaultOrganization`, |
| 131 | +`#isDefaultOrganizationBootstrapTrigger`) so that the endpoint's active-organization |
| 132 | +resolution has something to resolve. |
| 133 | + |
| 134 | +Two consequences this repository enforces: |
| 135 | + |
| 136 | +- **The gate is the organization capability, never multi-org.** The invite |
| 137 | + action declares `requiresFeature: 'organization'` |
| 138 | + (`packages/platform-objects/src/identity/sys-user.object.ts#requiresFeature`), |
| 139 | + and the spec's public roster keeps the two features apart: the member-management |
| 140 | + inputs sit under the organization capability while organization *lifecycle* |
| 141 | + actions sit under `packages/spec/src/kernel/public-auth-features.ts#multiOrgEnabled` |
| 142 | + (`#gatedInputs`). ADR-0093 D8 already records that this split is deliberate; |
| 143 | + it is cited, not re-argued. |
| 144 | +- **Every add flows through an invitation.** There is no bespoke user-CRUD path |
| 145 | + into membership. `invite_user` targets better-auth's invite-member endpoint and |
| 146 | + is declared on three objects, so it is reachable from wherever an admin |
| 147 | + happens to be looking: |
| 148 | + `packages/platform-objects/src/identity/sys-user.object.ts#invite_user`, |
| 149 | + `packages/platform-objects/src/identity/sys-invitation.object.ts#invite_user`, |
| 150 | + and `packages/platform-objects/src/identity/sys-member.object.ts#invite_user`. |
| 151 | + The three copies are held equal to each other — not to hand-copied literals — |
| 152 | + by `packages/platform-objects/src/identity/invite-entry-toolbar.test.ts#INVITE_ENDPOINT` |
| 153 | + and `#OBJECTS_BY_NAME`. |
| 154 | + |
| 155 | +⚠️ Membership rows are **read-only over the API**: `sys_member` declares |
| 156 | +`packages/platform-objects/src/identity/sys-member.object.ts#apiMethods` as reads |
| 157 | +only, and writes are owned by better-auth behind the identity write guard |
| 158 | +(ADR-0092 D2). "Open" here means *the affordance is not gated*, not *the table |
| 159 | +is writable*. |
| 160 | + |
| 161 | +### D3 — The in-shell surface is the organization RECORD page, reached by a templated nav deep-link |
| 162 | + |
| 163 | +What **this repository declares** is a navigation contribution: an `object`-typed |
| 164 | +Setup entry naming `sys_organization` with a templated `recordId` of |
| 165 | +`{current_org_id}`, in the People & Org group |
| 166 | +(`packages/platform-objects/src/apps/setup-nav.contributions.ts#nav_organization`). |
| 167 | +Its siblings — `#nav_teams`, `#nav_invitations` — carry **no** service gate, while |
| 168 | +the organization *list* keeps one (`#nav_organizations`, `requiresService: 'org-scoping'`): |
| 169 | +browsing organizations is meaningful only when more than one can exist. That |
| 170 | +asymmetry is D1 expressed in navigation. |
| 171 | + |
| 172 | +**The rendering half is objectui's, and this repository does not enforce it.** |
| 173 | +The console pinned by `.objectui-sha` substitutes the token and, when it cannot |
| 174 | +resolve, falls through to the list view rather than emitting a dead link |
| 175 | +(`objectui:packages/layout/src/NavigationRenderer.tsx#applyNavTemplate`, |
| 176 | +`#NavTemplateContext`, `#resolveHref`); related-list toolbars are bridged in |
| 177 | +declaration order by |
| 178 | +`objectui:packages/app-shell/src/views/RelatedRecordActionsBridge.tsx#deriveActions`. |
| 179 | +Those are objectui's behaviours, cited here so a reader can find them — ⛔ this |
| 180 | +record does not make them contracts of this repository, and a change to them is |
| 181 | +not a violation of this record. |
| 182 | + |
| 183 | +⚠️ **The tab set and its ordering are NOT declared by this repository, and this |
| 184 | +record does not decide them.** See |
| 185 | +[What this record does not decide](#what-this-record-does-not-decide). |
| 186 | + |
| 187 | +### D4 — Control-plane roster reads are organization-scoped |
| 188 | + |
| 189 | +`sys_member` is organization-scoped: it carries |
| 190 | +`packages/platform-objects/src/identity/sys-member.object.ts#organization_id` as a |
| 191 | +lookup to `sys_organization`, is uniquely indexed on the organization/user pair |
| 192 | +(`#indexes`), and pairs that column with `#user_id` and `#role`. The |
| 193 | +organization-capability gate on the identity surface is the same one D1 names. |
| 194 | + |
| 195 | +⛔ **This record does not restate what "org-scoped" now means — ADR-0131 owns it.** |
| 196 | +Since cloud ADR-0081 was accepted, [ADR-0131](./0131-total-organization-ownership-no-null-organization-id.md) |
| 197 | +(merged 2026-09-04) decided organization ownership for every row in the system, |
| 198 | +and it supersedes any reading of D4 as an opt-in property: |
| 199 | + |
| 200 | +- **D1/D9** — a row with an organization column was written by an organization, |
| 201 | + the column is `NOT NULL`, and a missing stamp is a refused write in every |
| 202 | + posture. Org-scoping is the default; the exception is a table with **no** |
| 203 | + column. |
| 204 | +- **D7** — whether a given object *keeps* the column at all is a **writer-facts** |
| 205 | + question decided per object by the C7 inventory, not by the object's name. |
| 206 | + |
| 207 | +⚠️ Consequently, **whether `sys_member` keeps its column is not decided here.** |
| 208 | +Today the field is declared optional and its own comment describes a null |
| 209 | +organization under single-tenancy — a reading ADR-0131 D1 does not permit going |
| 210 | +forward. That reconciliation belongs to ADR-0131's C6 census |
| 211 | +([#15207](https://github.com/objectstack-ai/objectstack/issues/15207)), and this |
| 212 | +record cites it rather than pre-empting it, exactly as it cites ADR-0093 rather |
| 213 | +than restating the membership lifecycle. |
| 214 | + |
| 215 | +--- |
| 216 | + |
| 217 | +## What this record does not decide |
| 218 | + |
| 219 | +This section is the honest residue: places where the cloud record is silent, or |
| 220 | +where this repository's code and its own comments do not agree. ⛔ None of it is |
| 221 | +resolved here. |
| 222 | + |
| 223 | +1. ⭐ **"Opens on tab-0 Members" is asserted in this repository's comments but |
| 224 | + declared by none of its metadata.** Two source comments and a QA checklist |
| 225 | + item describe the organization record page as opening on a Members tab with |
| 226 | + Invitations and Teams beside it. Measured on `origin/main` at `77781151d`, |
| 227 | + **no object in `packages/platform-objects/src/identity/` declares the |
| 228 | + `relatedList` prominence key** (`packages/spec/src/data/field.zod.ts#relatedList`) |
| 229 | + — the key objectui reads to promote a child list to its own tab — and no |
| 230 | + `relatedLayout` override exists anywhere in this repository. Under the |
| 231 | + documented default, with no primary list declared, related lists collapse |
| 232 | + into a single stacked tab. So the tab ordering is either an emergent property |
| 233 | + of the renderer or a claim that has gone stale; **this record states the |
| 234 | + deep-link contract, which is declared, and does not assert a tab order, which |
| 235 | + is not.** Filed separately rather than repaired here. |
| 236 | + |
| 237 | +2. **Whether `sys_member` keeps `organization_id`** — ADR-0131 D7's writer-facts |
| 238 | + question, answered by the C6 census (#15207). See D4. |
| 239 | + |
| 240 | +3. **The reconciliation of the optional column with ADR-0131 D1.** The field is |
| 241 | + declared optional today and ADR-0131 D1 says no nullable tenant column exists |
| 242 | + anywhere. That is a migration question on ADR-0131's own v18 line (its D10 |
| 243 | + and D14), not a mirror's to settle. |
| 244 | + |
| 245 | +4. **What an enterprise subscription buys, and where the multi-organization |
| 246 | + machinery lives.** Cloud ADR-0081 D2 and the record proposed in #16215, |
| 247 | + respectively. ⛔ Not evaluated here. |
| 248 | + |
| 249 | +--- |
| 250 | + |
| 251 | +## Consequences |
| 252 | + |
| 253 | +**Good.** The three local records that already carry pieces of this decision — |
| 254 | +ADR-0093 D9, ADR-0105 D12, ADR-0131 D1/D7/D9 — stop being the only local anchors |
| 255 | +for a decision none of them owns. A reader who follows an `ADR-0081 D1` citation |
| 256 | +out of the identity surface can now be sent somewhere that answers, which is the |
| 257 | +precondition for #14361's re-pointing work. |
| 258 | + |
| 259 | +**Costs, stated plainly.** One decision is now recorded in two repositories, and |
| 260 | +mirrors drift: this file has to be corrected when cloud ADR-0081 changes, and |
| 261 | +nothing mechanical will notice. The mitigation is the Provenance section's |
| 262 | +precedence rule (the cloud record decides) rather than a gate. The cloud record |
| 263 | +also owes a one-line pointer back to this number; that edit belongs to the cloud |
| 264 | +repository and is not made by the change that lands this file. |
| 265 | + |
| 266 | +**Reversibility.** Total — this file decides nothing, so deleting it costs only |
| 267 | +the citations that point at it. |
0 commit comments