Skip to content

Commit c677cda

Browse files
hotlongclaude
andauthored
feat(organizations): bring the multi-organization runtime back to open core — the org-scoping registrar ships open, the licence gate stays in cloud (ADR-0132) (#16215)
* feat(organizations): the multi-organization runtime returns to open core (#16130) Creates packages/plugins/organizations — @objectstack/organizations — carrying the 1300 lines cloud's packages/organizations classified as moving. The licence gate (306 lines) stays in the commercial repo, which will subclass this class and call it in its own constructor. WIP: ADR-0132 and the changeset still to come. Claude-Session: https://claude.ai/code/session_6679d191-11f4-465b-b322-0e0409d76793 Co-authored-by: Claude <noreply@anthropic.com> * docs(adr): ADR-0132 — the multi-organization runtime is open core (#16130) Amends ADR-0105 D12 in place, corrects the three open-tree claims the move falsifies (plugin-security README, the spec roster row, the multi-node carrier header), and adds the changeset. Claude-Session: https://claude.ai/code/session_6679d191-11f4-465b-b322-0e0409d76793 Co-authored-by: Claude <noreply@anthropic.com> * fix(organizations): satisfy the open tree's gates the closed package never met (#16130) The moved code arrives in a repo with gates cloud's does not run. Each finding was a real one, fixed rather than baselined: - slot-lookup: the `objectql` / `metadata` lookups are typed structurally instead of erased to `any` — that ratchet's baseline never grows. - engine-double-contract: the three moved test fakes open `update()` with `assertEngineUpdateDispatch`, pinning them to the producer's rejection. - doc-authoring: tracker ids leave the rendered boot refusal and the log line; an operator cannot resolve `#NNNN`. - dispatcher-error-vocabulary: `WALLED_MEMBERSHIP_POLICY_UNDECLARED` is recorded as a boot-refusal, the class its neighbours are ruled by. - system-context census: the `organization_id` auto-stamp's elevation short-circuit gets its row (62) and the page's counts move with it. - cross-package-test-inputs + turbo: the ADR-0132 D3 pin declares its radius. - test-typecheck: measured zero, so the package carries no ledger and no generator, matching the ten zero-residue plugin packages. - llms.txt: 68 → 69 published packages. Claude-Session: https://claude.ai/code/session_6679d191-11f4-465b-b322-0e0409d76793 Co-authored-by: Claude <noreply@anthropic.com> * fix(organizations): type-source resolution, the WHERE double, and the last slot erasure (#16130) - tsconfig `paths` + a vitest alias so every verdict in this package is about the source in the checkout, not the last build. That covers the test-only `@objectstack/metadata-core` too, which the moved fakes open `update()` with. - `packages/qa/dogfood` gains the same rule for `@objectstack/organizations`: the name became a workspace package with this PR, and that suite's doc comment spells a literal dynamic import of it. - the moved `walled-default-org-self-registrant` fake REFUSES a WHERE combinator instead of reading it as a field name — silently wrong is the shape that would let a negative assertion read "no rows" as evidence. - the last `getService(...) as any` in the moved suite goes through the harness's own handle. - engine-double-contract ledger learns the three newly pinned fakes. - census self-test literal moves with the page it asserts against. Claude-Session: https://claude.ai/code/session_6679d191-11f4-465b-b322-0e0409d76793 Co-authored-by: Claude <noreply@anthropic.com> * chore(docs): regenerate the isSystem census anchors after merging main (#16130) The merge driver deferred `content/docs/permissions/system-context.mdx` — it is a `merge=os-regen` artifact and git ran no text merge on it. This discharges that deferral from the merged tree, as `.githooks/pre-push` requires. Anchor lines only: 18 rewritten, all of them `<file>.ts:<line>` citations the incoming commits moved. Row 62, the section extents and every declared count are unchanged, and `check:system-context-census` is green on the merged tree. Claude-Session: https://claude.ai/code/session_6679d191-11f4-465b-b322-0e0409d76793 Co-authored-by: Claude <noreply@anthropic.com> * chore(docs): regenerate the isSystem census anchors after merging main (#16130) Second merge round, same deferral: `content/docs/permissions/system-context.mdx` is a `merge=os-regen` artifact, git ran no text merge on it, and the driver recorded it in `os-regen-pending`. This discharges that deferral from the merged tree, as `.githooks/pre-push` requires. Anchor lines only: 15 rewritten, every one a `file.ts:line` citation the incoming commits moved. Proven rather than asserted — diffing the page against its pre-regeneration copy and filtering out anchor-bearing lines leaves nothing, row 62 is byte-identical, and the whole declared-counts table is byte-identical (106 sites / 20 packages / 45 files, rows 1-62 behaviour, rows 63-66 carry-onward). --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent acabd24 commit c677cda

36 files changed

Lines changed: 4260 additions & 34 deletions

.changeset/config.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
"@objectstack/plugin-dev",
4242
"@objectstack/plugin-email",
4343
"@objectstack/plugin-hono-server",
44+
"@objectstack/organizations",
4445
"@objectstack/mcp",
4546
"@objectstack/plugin-pinyin-search",
4647
"@objectstack/plugin-reports",
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
'@objectstack/organizations': minor
3+
'@objectstack/plugin-security': patch
4+
'@objectstack/service-cluster': patch
5+
'@objectstack/spec': patch
6+
---
7+
8+
Ship the multi-organization runtime as open source: `@objectstack/organizations` is now an
9+
Apache-2.0 package in this repository (ADR-0132).
10+
11+
Single-database, row-level organization isolation was already open — the tenant Layer 0 wall,
12+
the three tenancy postures, the organization and invitation objects, better-auth's organization
13+
plugin, and the `requiresService: 'org-scoping'` Setup gates. What was closed was the one
14+
registrar of the `org-scoping` service, so an install that set `OS_TENANCY_POSTURE=isolated`
15+
could not enforce it: `serve` refused the boot, and the only way past was
16+
`OS_ALLOW_DEGRADED_TENANCY=1` — the wall configured but not enforced. This package is that
17+
missing registrar.
18+
19+
It provides:
20+
21+
- **`organization_id` auto-stamp on insert**, from the caller's active organization. A supplied
22+
— possibly forged — value is overwritten, never trusted.
23+
- **Per-organization seed replay** on `sys_organization` insert, from the app's own seed
24+
definitions. Never another organization's rows.
25+
- **Default-organization bootstrap** for the platform admin, idempotent.
26+
- **The walled-posture membership-policy gate**: a deployment that raises the wall must declare
27+
what a new user joins, or the boot is refused.
28+
29+
Only the commercial **entitlement** stays closed. The open class carries no licence check of any
30+
kind and offers no hook for one; an enterprise deployment resolves the same package name to a
31+
private, licence-gated subclass through its own `workspace:*` declaration, so which class is
32+
mounted is decided by the manifest that declares the name.
33+
34+
⚠️ Shipping the registrar is not yet the same as an open install raising the wall: `objectstack
35+
serve` still resolves the runtime from the served app's own declaration and is not yet wired to
36+
mount this package off `OS_TENANCY_POSTURE`. That, and the isolation matrix run against a real
37+
registrar rather than a posture stub, are tracked separately.

content/docs/permissions/system-context.mdx

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ the seed loader replaying package fixtures, a plugin's boot reconciler, a
99
service self-write, a migration.
1010

1111
This page is **the authority** for what that flag actually does. It exists
12-
because the flag is not one concept: it is a single boolean read at **105
13-
distinct sites across 19 packages**, and knowing three of those behaviours gives
12+
because the flag is not one concept: it is a single boolean read at **106
13+
distinct sites across 20 packages**, and knowing three of those behaviours gives
1414
no hint that the other hundred-and-three exist. Every documented app-side bug
1515
traced to `isSystem` had the same shape — the metadata was complete and correct,
1616
and the gap was observable only by querying the resulting rows.
@@ -124,7 +124,7 @@ that silently does not happen.
124124

125125
### 3. Sharing (`plugin-sharing`)
126126

127-
The largest single consumer — **17 of the 105 sites**.
127+
The largest single consumer — **17 of the 106 sites**.
128128

129129
| # | Behaviour when `isSystem` | What you get / what you lose | Anchor |
130130
|:--|:---|:---|:---|
@@ -152,7 +152,7 @@ The largest single consumer — **17 of the 105 sites**.
152152
| 46 | Comment access hooks return early (insert + update + delete, and the read AST) | plugin-audit | Lose: comment visibility scoping | `comment-access-hooks.ts:322`, `:449`, `:488`, `:540` |
153153
| 47 | Knowledge search returns hits unfiltered | service-knowledge | Lose: the permission filter over search results | `service-knowledge/src/knowledge-service.ts:316` |
154154

155-
### 5. Actions, metadata plane, provenance
155+
### 5. Actions, metadata plane, provenance, the organization wall
156156

157157
| # | Behaviour when `isSystem` | Package | What you get / what you lose | Anchor |
158158
|:--|:---|:---|:---|:---|
@@ -170,6 +170,7 @@ The largest single consumer — **17 of the 105 sites**.
170170
| 59 | Email-template / webhook provenance stamps skipped | plugin-email, plugin-webhooks | Lose: the row is not marked as an admin customization | `email-template-provenance.ts:77`, `webhook-provenance.ts:68` |
171171
| 60 | **Automation flow data nodes re-add the `owner_id` stamp** (the one place row 2's gap is compensated inline) | service-automation | Get: a flow-authored INSERT under system elevation still lands owned, when the run resolved a user. Fill-only — flow-authored values win | `runtime-identity.ts:279`, called from `builtin/crud-nodes.ts:319` |
172172
| 61 | Inbox caller refusal names `isSystem` as what was carried | service-messaging | Get: nothing — the refusal still fires. The flag only shapes the diagnostic, because privilege is not an authorization subject | `inbox-caller.ts:148` |
173+
| 62 | **`organization_id` is not auto-stamped on INSERT** — the organization-axis twin of the `owner_id` gap above | organizations | Get: an elevated write may name another organization deliberately, which is what the per-organization seed replay, the orphan-row claim, imports and migrations all rely on. Lose: the authoritative stamp, so an elevated insert that names no organization lands `organization_id = NULL` and the wall hides it. ⛔ This is why a forged `organization_id` is overwritten on the non-elevated path and not here: elevation is the seam the legitimate cross-organization writers use | `organizations-plugin.ts:302` |
173174

174175
### 6. Reads that only carry the flag onward
175176

@@ -179,10 +180,10 @@ a reader tracing where elevation travels needs them.
179180

180181
| # | Site | Package | What it does |
181182
|:--|:---|:---|:---|
182-
| 62 | `objectql/src/engine.ts:3737` | objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes |
183-
| 63 | `objectql/src/engine.ts:15016` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag |
184-
| 64 | `plugin-reports/src/report-service.ts:556` | plugin-reports | Threads the flag into the engine call that runs a report |
185-
| 65 | `body-runner.ts:279` | runtime | Rebuilds an `ExecutionContext` from a hook session, carrying the flag across |
183+
| 63 | `objectql/src/engine.ts:3737` | objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes |
184+
| 64 | `objectql/src/engine.ts:15016` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag |
185+
| 65 | `plugin-reports/src/report-service.ts:556` | plugin-reports | Threads the flag into the engine call that runs a report |
186+
| 66 | `body-runner.ts:279` | runtime | Rebuilds an `ExecutionContext` from a hook session, carrying the flag across |
186187

187188
---
188189

@@ -269,8 +270,8 @@ Ownership injection, `readonly` bypass and sharing materialisation are
269270
independent decisions, and a seed loader plausibly wants the first two but not
270271
the third. The concept is nevertheless **staying as one boolean**:
271272

272-
- **Shipped semantics.** `isSystem` is a published contract with 105 read sites
273-
in 19 packages. Splitting it is a breaking contract change across all of them.
273+
- **Shipped semantics.** `isSystem` is a published contract with 106 read sites
274+
in 20 packages. Splitting it is a breaking contract change across all of them.
274275
(The ruling was taken when the census read 80 sites in 18 packages; the count
275276
has grown, which strengthens rather than weakens the argument.)
276277
- **No business pull.** No app has asked for the combinations a split would
@@ -326,15 +327,15 @@ still holds equal to the census on every pull request:
326327
| Appearances of the bare identifier `isSystem` in non-test sources | 813 ||
327328
| — parsed as a declaration | 22 ||
328329
| — parsed as an object-literal / type key (producers and option objects) | 310 ||
329-
| — parsed as a property **read** | 111 ||
330+
| — parsed as a property **read** | 112 ||
330331
| — parsed in some other syntactic position (a local, a cast, a conditional) | 9 ||
331332
| — the remainder: text inside comments and string literals | 358 ||
332333
| Of those reads: reads of one of the unrelated metadata fields | 6 ||
333-
| Of those reads: reads of `ExecutionContext.isSystem` | **105** ||
334-
| — behaviour-bearing (rows 1–61 above) | 101 ||
335-
| — carry the flag onward only (rows 62–65 above) | 4 ||
336-
| Packages containing at least one elevation read | **19** ||
337-
| Files containing at least one elevation read | 44 ||
334+
| Of those reads: reads of `ExecutionContext.isSystem` | **106** ||
335+
| — behaviour-bearing (rows 1–62 above) | 102 ||
336+
| — carry the flag onward only (rows 63–66 above) | 4 ||
337+
| Packages containing at least one elevation read | **20** ||
338+
| Files containing at least one elevation read | 45 ||
338339

339340
The six rows marked — are a **dated decomposition, not a live claim**: they were
340341
measured on 2026-08-29 at `ca1965f2b5` and CI does not re-derive them. They count

docs/adr/0105-group-tenancy-posture-and-first-class-org-scope.md

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# ADR-0105: Group Tenancy Posture — Organization Scope as a First-Class Authorization Dimension
22

3-
**Status**: Accepted (2026-07-27; proposed 2026-07-25) — Phase 0/1 implemented (#3559). Amended 2026-07-27: **D12 correction**`group` posture activation is entitled, not open (#3570; see the D12 Amendment). Phase 2 **D8** and **D9** implemented 2026-07-28 — D8: #3645 (host seam) → #3663 (placement engine) → #3674 (`/security/my-delegable-scope`) → #3695 (issuer-grant resolution) → #3722 (`delegated_admin` + invitation role cap, #3697) → #3767 (`sys_member` governed), console objectui#2868/#2891, e2e cloud#886; the membership-role channel D8's placement replaces is closed by [ADR-0108](./0108-membership-grade-is-not-a-capability-channel.md). D9: #3824 + #3873 (see the D9 amendment below). D10 **withdrawn** 2026-09-04 by maintainer ruling (「不考虑集团级模板行,作废相关需求」「不考虑 分层主数据」; recorded in [ADR-0131](./0131-total-organization-ownership-no-null-organization-id.md) D12 — see the note under D10); D13 not started
3+
**Status**: Accepted (2026-07-27; proposed 2026-07-25) — Phase 0/1 implemented (#3559). Amended 2026-07-27: **D12 correction** — `group` posture activation is entitled, not open (#3570; see the D12 Amendment). Phase 2 **D8** and **D9** implemented 2026-07-28 — D8: #3645 (host seam) → #3663 (placement engine) → #3674 (`/security/my-delegable-scope`) → #3695 (issuer-grant resolution) → #3722 (`delegated_admin` + invitation role cap, #3697) → #3767 (`sys_member` governed), console objectui#2868/#2891, e2e cloud#886; the membership-role channel D8's placement replaces is closed by [ADR-0108](./0108-membership-grade-is-not-a-capability-channel.md). D9: #3824 + #3873 (see the D9 amendment below). **D12 amended 2026-09-06** by [ADR-0132](./0132-multi-organization-runtime-is-open-core.md) — the multi-org runtime moves to open core; the entitlement stays commercial. D10 **withdrawn** 2026-09-04 by maintainer ruling (「不考虑集团级模板行,作废相关需求」「不考虑 分层主数据」; recorded in [ADR-0131](./0131-total-organization-ownership-no-null-organization-id.md) D12 — see the note under D10); D13 not started
44
**Deciders**: ObjectStack Protocol Architects
55
**Builds on**: [ADR-0049](./0049-no-unenforced-security-properties.md) (enforce-or-remove), [ADR-0057](./0057-erp-authorization-core-business-units-and-scope-depth.md) (business units + scope depth), [ADR-0066](./0066-unified-authorization-model.md) (unified authz, superuser bypass), [ADR-0086](./0086-authz-metadata-config-boundary-and-cross-package-composition.md), [ADR-0090](./0090-permission-model-v2-concept-convergence.md) (permission set / position / business unit), [ADR-0091](./0091-grant-lifecycle-and-recertification.md) (validity windows), [ADR-0092](./0092-sys-user-profile-field-delegation.md) (identity write guard + field whitelist), [ADR-0093](./0093-tenancy-mode-and-membership-lifecycle.md) (tenancy service), [ADR-0095](./0095-authz-kernel-tenant-layer-and-posture-ladder.md) (tenant Layer 0, posture ladder), [ADR-0103](./0103-managedby-write-policy-and-engine-write-guard.md); cloud ADR-0016 (open/paid boundary: 强制免费、治理收费), cloud ADR-0081 (`@objectstack/organizations`)
66
**Tracking**: #3541 (P0 findings F1/F2 became #3539/#3540, closed by #3559); cloud-side tracking cloud #874
@@ -324,7 +324,10 @@ reserves the concept and its place in Phase 2.
324324

325325
**D12 — Edition split, per the cloud ADR-0016 iron rule (强制免费、治理收费).**
326326
*(As amended 2026-07-27, #3570 — see the Amendment below for the original
327-
text and why it was wrong.)* The split is **code vs. activation**, not code
327+
text and why it was wrong; and again 2026-09-06 by
328+
[ADR-0132](./0132-multi-organization-runtime-is-open-core.md), which moves the
329+
multi-org RUNTIME to open core and leaves only the entitlement commercial — see
330+
the ADR-0132 Amendment at the end of this section.)* The split is **code vs. activation**, not code
328331
vs. code. The wall's *implementation* ships open — D3/D4 correctness, the
329332
Layer 0 predicates, D5 stamping/validation, `accessible_org_ids` resolution,
330333
the D6 red-line lints — exactly as `isolated`'s wall has always lived in
@@ -342,6 +345,29 @@ org lifecycle management, grouping/registry UI, scoped invitations UX,
342345
cross-org approval templates, master-data distribution management, per-org
343346
seed/config replay, org analytics, and the D13 promotion tooling.
344347

348+
> **Amendment (2026-09-06, [ADR-0132](./0132-multi-organization-runtime-is-open-core.md)).**
349+
> The *split itself* stands: it is still code vs. activation, and enabling
350+
> multi-organization operation is still an entitlement on the commercial side.
351+
> What ADR-0132 changes is **where the code lives**. The multi-org runtime
352+
> `@objectstack/organizations` now ships from this repository under Apache-2.0;
353+
> the commercial repository keeps a private package of the same name whose class
354+
> subclasses the open one and calls its licence gate in its own constructor, and
355+
> every commercial host resolves that name through a `workspace:*` declaration
356+
> that can only reach the local package. So this section's sentences about the
357+
> runtime being closed-source read as history: the paragraph below still
358+
> describes how activation is gated, on the commercial side, and no longer
359+
> describes who may read the source.
360+
>
361+
> Two consequences for this section specifically. **The `supportedPostures`
362+
> declaration**: D12's argument that "which shapes of multi-org" is a packaging
363+
> decision belonging in the commercial runtime does not carry to the open
364+
> package, which entitles both walled postures by construction (ADR-0132 D4) —
365+
> the commercial runtime may still narrow what it entitles. **The
366+
> boot refusal**: ADR-0093 D5 still refuses a walled posture with no runtime
367+
> present, but "absent" stops being the normal state for an open install once
368+
> #16137 wires `serve` to the open registrar. ⛔ ADR-0132 does not itself
369+
> deliver that; it ships the registrar.
370+
345371
> **Citation note (2026-08-16) — hygiene, not a decision.** Code and tests
346372
> carried this entitlement as **"ADR-0081 D2"**, a label inherited from a
347373
> decision record that predates this repo's ADR series — the same pre-repo

0 commit comments

Comments
 (0)