Skip to content

Commit 13c0835

Browse files
os-warrenclaude
andauthored
docs(adr): ADR-0133 — organization management, the open basics; mirroring cloud ADR-0081 D1/D3/D4 with Provenance (#16267)
* 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 * docs(adr): ADR-0133 cites the landed ADR-0132 for the D2 reversal The record that reverses cloud ADR-0081 D2 has landed on `main` as ADR-0132, so this file's account of it is stale: it described that record as an open draft PR, cited it by PR number because an unmerged ADR number was a squat, and still said D2's packaging decision "stays in cloud". - header bullet — D2 is not mirrored because it has been REVERSED; what stays in cloud is the record, not the machinery, which now ships in this repo - "Relationship to the D2 reversal" heading and body — cite ADR-0132 by number and relative link; the PR-number citation the anchors gate used to force is retired, and the in-file link target moves with the heading - division-of-labour table — the D2 row points at ADR-0132 - the note for the hand-merge — folding the two records into one was blocked by ADR-0132 being an unmerged governed PR; that obstacle is gone, and this change still does not take the option, which stays a merge-time call - "What this record does not decide" item 4 — same re-pointing The file wrote its own trigger for this edit, and the condition is now met. Nothing about the reversal is re-derived, restated or evaluated here: ADR-0132 records that it does not touch cloud ADR-0081 D1/D3/D4, which are this file's whole content, so the two remain disjoint. `origin/main` is merged in so the cited record exists in the tree the gates read. Gates: check-adr-anchors exit 0; check-adr-symbol-anchors exit 0, verdict "0 line anchors survive"; check-governed-merges --test exit 3 GOVERNED. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y * docs(adr): name the obstacle to folding that survived ADR-0132's merge The hand-merge note said the only thing blocking a single combined record was ADR-0132 being unmerged, and that with it landed the choice "stands on its merits alone". True, but it leaves the reader to rediscover the merit that matters: that record carries its own D1 through D6, which are different decisions from cloud ADR-0081's D1/D3/D4, so folding this mirror into it puts two unrelated D1s in one document — exactly the property the deliberately unrenumbered mirror exists to keep checkable. Stated, not decided: the disposition is still the maintainer's merge-time call, and this change takes the option neither way. Gates re-run on this tree: check-adr-anchors exit 0, check-adr-symbol-anchors exit 0 with "0 line anchors survive", check-adr-links exit 0, check-nul-bytes exit 0, check-doc-authoring exit 0, check-governed-merges --test exit 3 GOVERNED. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 8976ea1 commit 13c0835

1 file changed

Lines changed: 273 additions & 0 deletions

File tree

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

Comments
 (0)