Skip to content

Commit d9954a5

Browse files
os-warrenclaude
andcommitted
docs(adr): ADR-0133 — organization management open basics, mirroring cloud ADR-0081 D1/D3/D4
Records, in the repository whose code enforces them, the open-mechanism half of cloud ADR-0081 (Accepted 2026-07-09): D1 teammate-add stays open and always goes through better-auth invitations, D3 the in-shell surface is the organization record page reached by a templated nav deep-link, D4 control-plane roster reads are organization-scoped. Decides nothing. D2, the non-goals and the commercial consequences stay in cloud and are cited as `cloud ADR-0081`. Where a local record already decides a point it is cited rather than duplicated: ADR-0093 (membership lifecycle, D9's active- organization resolution), ADR-0105 D12 (the multi-org entitlement anchor), and ADR-0131 D1/D7/D9 (total organization ownership), whose C6 census (#15207) — not this file — answers whether sys_member keeps its organization column. Every code anchor was re-located on origin/main at 7778115 and is written as a symbol anchor, since check:adr-symbol-anchors makes a line number a hard finding. The objectui rendering half is cited as a cross-repo anchor and is explicitly not claimed as this repository's contract. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
1 parent 7778115 commit d9954a5

1 file changed

Lines changed: 267 additions & 0 deletions

File tree

Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
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

Comments
 (0)