Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"@objectstack/plugin-dev",
"@objectstack/plugin-email",
"@objectstack/plugin-hono-server",
"@objectstack/organizations",
"@objectstack/mcp",
"@objectstack/plugin-pinyin-search",
"@objectstack/plugin-reports",
Expand Down
37 changes: 37 additions & 0 deletions .changeset/open-core-multi-organization-runtime.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
'@objectstack/organizations': minor
'@objectstack/plugin-security': patch
'@objectstack/service-cluster': patch
'@objectstack/spec': patch
---

Ship the multi-organization runtime as open source: `@objectstack/organizations` is now an
Apache-2.0 package in this repository (ADR-0132).

Single-database, row-level organization isolation was already open — the tenant Layer 0 wall,
the three tenancy postures, the organization and invitation objects, better-auth's organization
plugin, and the `requiresService: 'org-scoping'` Setup gates. What was closed was the one
registrar of the `org-scoping` service, so an install that set `OS_TENANCY_POSTURE=isolated`
could not enforce it: `serve` refused the boot, and the only way past was
`OS_ALLOW_DEGRADED_TENANCY=1` — the wall configured but not enforced. This package is that
missing registrar.

It provides:

- **`organization_id` auto-stamp on insert**, from the caller's active organization. A supplied
— possibly forged — value is overwritten, never trusted.
- **Per-organization seed replay** on `sys_organization` insert, from the app's own seed
definitions. Never another organization's rows.
- **Default-organization bootstrap** for the platform admin, idempotent.
- **The walled-posture membership-policy gate**: a deployment that raises the wall must declare
what a new user joins, or the boot is refused.

Only the commercial **entitlement** stays closed. The open class carries no licence check of any
kind and offers no hook for one; an enterprise deployment resolves the same package name to a
private, licence-gated subclass through its own `workspace:*` declaration, so which class is
mounted is decided by the manifest that declares the name.

⚠️ Shipping the registrar is not yet the same as an open install raising the wall: `objectstack
serve` still resolves the runtime from the served app's own declaration and is not yet wired to
mount this package off `OS_TENANCY_POSTURE`. That, and the isolation matrix run against a real
registrar rather than a posture stub, are tracked separately.
33 changes: 17 additions & 16 deletions content/docs/permissions/system-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ the seed loader replaying package fixtures, a plugin's boot reconciler, a
service self-write, a migration.

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

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

The largest single consumer — **17 of the 105 sites**.
The largest single consumer — **17 of the 106 sites**.

| # | Behaviour when `isSystem` | What you get / what you lose | Anchor |
|:--|:---|:---|:---|
Expand Down Expand Up @@ -152,7 +152,7 @@ The largest single consumer — **17 of the 105 sites**.
| 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` |
| 47 | Knowledge search returns hits unfiltered | service-knowledge | Lose: the permission filter over search results | `service-knowledge/src/knowledge-service.ts:316` |

### 5. Actions, metadata plane, provenance
### 5. Actions, metadata plane, provenance, the organization wall

| # | Behaviour when `isSystem` | Package | What you get / what you lose | Anchor |
|:--|:---|:---|:---|:---|
Expand All @@ -170,6 +170,7 @@ The largest single consumer — **17 of the 105 sites**.
| 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` |
| 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` |
| 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` |
| 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` |

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

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

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

---

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

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

The six rows marked — are a **dated decomposition, not a live claim**: they were
measured on 2026-08-29 at `ca1965f2b5` and CI does not re-derive them. They count
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# ADR-0105: Group Tenancy Posture — Organization Scope as a First-Class Authorization Dimension

**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
**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
**Deciders**: ObjectStack Protocol Architects
**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`)
**Tracking**: #3541 (P0 findings F1/F2 became #3539/#3540, closed by #3559); cloud-side tracking cloud #874
Expand Down Expand Up @@ -324,7 +324,10 @@ reserves the concept and its place in Phase 2.

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

> **Amendment (2026-09-06, [ADR-0132](./0132-multi-organization-runtime-is-open-core.md)).**
> The *split itself* stands: it is still code vs. activation, and enabling
> multi-organization operation is still an entitlement on the commercial side.
> What ADR-0132 changes is **where the code lives**. The multi-org runtime
> `@objectstack/organizations` now ships from this repository under Apache-2.0;
> the commercial repository keeps a private package of the same name whose class
> subclasses the open one and calls its licence gate in its own constructor, and
> every commercial host resolves that name through a `workspace:*` declaration
> that can only reach the local package. So this section's sentences about the
> runtime being closed-source read as history: the paragraph below still
> describes how activation is gated, on the commercial side, and no longer
> describes who may read the source.
>
> Two consequences for this section specifically. **The `supportedPostures`
> declaration**: D12's argument that "which shapes of multi-org" is a packaging
> decision belonging in the commercial runtime does not carry to the open
> package, which entitles both walled postures by construction (ADR-0132 D4) —
> the commercial runtime may still narrow what it entitles. **The
> boot refusal**: ADR-0093 D5 still refuses a walled posture with no runtime
> present, but "absent" stops being the normal state for an open install once
> #16137 wires `serve` to the open registrar. ⛔ ADR-0132 does not itself
> deliver that; it ships the registrar.

> **Citation note (2026-08-16) — hygiene, not a decision.** Code and tests
> carried this entitlement as **"ADR-0081 D2"**, a label inherited from a
> decision record that predates this repo's ADR series — the same pre-repo
Expand Down
Loading
Loading