Skip to content
Merged
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
23 changes: 23 additions & 0 deletions .changeset/organizations-open-core-prose.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@objectstack/cli': patch
'@objectstack/plugin-dev': patch
---

Operator-facing text no longer tells an open-source install that multi-organization
operation requires a subscription.

ADR-0132 moved the `org-scoping` registrar into open core — `@objectstack/organizations`
is Apache-2.0, carries no licence check, and declares both walled postures (`group` and
`isolated`) as its own constant. The messages an operator actually reads had not followed:

- `os serve`'s install remedy for a walled posture ended "this runtime is closed-source and
is NOT on the public npm registry ... Without one this bullet is not followable" — it now
says the runtime is Apache-2.0 and on the public registry, and notes that a commercial
deployment resolves the same package name to its own private, licence-gated build.
- The `isolated` posture hint rendered by `os serve` and `os doctor` no longer calls the
runtime "enterprise".
- `os verify`'s `--org-scoped` flag description drops the same word.
- The dev stack's degraded-tenancy warning and its stage-2 mount refusal no longer describe
the package as the enterprise runtime.

Text only — no control flow, no identifiers, no behaviour change.
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ describe('doctor — the posture description an operator reads names the declara
// what is pinned.
it('leg (i) — the `isolated` fix-list bullet names it, with the spacing intact', () => {
expect(renderIsolatedBullet()).toBe(
` • OS_TENANCY_POSTURE=isolated — organization wall + the enterprise ${PKG} runtime `
` • OS_TENANCY_POSTURE=isolated — organization wall + the ${PKG} runtime `
+ "(the legacy spelling 'multi' is accepted and normalizes to this)",
);
});
Expand All @@ -183,8 +183,16 @@ describe('doctor — the posture description an operator reads names the declara
+ 'where it ships from (#10921); a command that prints a package name at operators must name a row in it.',
).toContain(PKG);

// Provenance, read through doctor's own spelling rather than a literal: the
// row this advice describes is the enterprise one.
// Provenance, read through doctor's own spelling rather than a literal.
//
// ⚠️ The row stays `edition: 'enterprise'` after ADR-0132 and that is NOT a
// contradiction of the reworded hint above. One name now denotes two
// packages (ADR-0132 D3): the framework publishes an Apache-2.0 build of it
// and a commercial deployment resolves the same name to a private,
// licence-gated subclass. The roster row records the ENTITLED variant — its
// own note says so — while the operator hint names the PACKAGE, which is
// followable on any install. So this assertion still measures roster drift,
// and it is deliberately not relaxed to match the prose reword.
const row = PLATFORM_PLUGIN_WIRED_RUNTIMES[PKG];
expect(row.edition, `edition drift for the runtime doctor names ('${PKG}')`).toBe('enterprise');
});
Expand Down Expand Up @@ -233,14 +241,14 @@ describe('#12464 CONTROL — these pins can say no', () => {
// If this instrument could not tell the two apart, leg (i) would be
// decorative. Anchored on the exact regression interpolation invites.
const expected =
` • OS_TENANCY_POSTURE=isolated — organization wall + the enterprise ${PKG} runtime `
` • OS_TENANCY_POSTURE=isolated — organization wall + the ${PKG} runtime `
+ "(the legacy spelling 'multi' is accepted and normalizes to this)";
expect(
` • OS_TENANCY_POSTURE=isolated — organization wall + the enterprise ${PKG} runtime`
` • OS_TENANCY_POSTURE=isolated — organization wall + the ${PKG} runtime`
+ "(the legacy spelling 'multi' is accepted and normalizes to this)",
).not.toBe(expected);
expect(
` • OS_TENANCY_POSTURE=isolated — organization wall + the enterprise ${PKG} runtime `
` • OS_TENANCY_POSTURE=isolated — organization wall + the ${PKG} runtime `
+ "(the legacy spelling 'multi' is accepted and normalizes to this)",
).not.toBe(expected);
// …and says yes to the real thing, so the two `not.toBe`s above are a
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
* binding. A load placed above it is not a compile error — the author writes
* a bare `import()`, which resolves from the CLI and is green in any dev
* checkout where everything is hoisted into one `node_modules`. The first
* time it cost the enterprise organizations load (cloud#1013); the second
* time it cost the organizations load (cloud#1013); the second
* time it cost EE multi-node boot outright (#10645).
*
* #10769 closed the class rather than hoisting a third time: the helper is
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,28 @@
* via `test/serve-capability-vocabulary.test.ts` and the shared const's via
* doctor's leg (ii).
*
* ── The 2026-09-08 reword, and why it was made HERE rather than absorbed ──
*
* These pins previously expected the word "enterprise" in two operator-facing
* sentences: the install remedy ("add … (the enterprise multi-org runtime)")
* and the `isolated` posture hint. ADR-0132 moved the multi-org runtime into
* open core — `packages/plugins/organizations` is Apache-2.0 and carries no
* licence check — so both sentences told an open-source operator that a remedy
* they can in fact follow required a subscription.
*
* ⛔ The pins were NOT relaxed or deleted to accommodate the reword: they still
* compare the whole rendered line, whitespace included, against text built from
* `Serve.ORGANIZATIONS_RUNTIME_PKG`, and the #12151 CONTROL block below still
* proves they can say no. Only the expected PROSE moved, in the same diff as
* the prose itself, which is the shape this file is for — a wording change that
* does not redden a pin here would mean the pin had stopped reading.
*
* ⚠️ ONE NAME, TWO PACKAGES (ADR-0132 D3) is why the new wording names no
* edition at all: a commercial deployment resolves the same package name to a
* private licence-gated subclass, so any adjective this message picks is wrong
* for one of the two installs reading it. The roster keeps the edition fact,
* and `doctor-organizations-message-spelling.test.ts` leg (ii) still pins it.
*
* ⚠️ This paragraph used to say the literal HAD to stay in `serve.ts`, because
* `serve-cluster-host-resolution.test.ts` resolved the organizations `import()`
* through that static and needed the literal in that file or the load dropped
Expand Down Expand Up @@ -152,7 +174,7 @@ describe('serve — the multi-org runtime name an operator READS comes from the
it('site 2 — the "add it to THIS APP" remedy names it, with the spacing intact', () => {
// The other instruction an operator acts on: the app never declared it.
expect(lines(remedyUndeclared())[0]).toBe(
` • add ${PKG} (the enterprise multi-org runtime) to THIS APP`,
` • add ${PKG} (the multi-org runtime) to THIS APP`,
);
});

Expand All @@ -164,7 +186,7 @@ describe('serve — the multi-org runtime name an operator READS comes from the
// package's own `exports` names no runtime entry Node can load — and it
// fell into the else leg, rendering the UNDECLARED remedy verbatim:
//
// • add @objectstack/organizations (the enterprise multi-org runtime) to THIS APP
// • add @objectstack/organizations (the multi-org runtime) to THIS APP
// — declare it in the app's package.json and install; the CLI resolves it from the
//
// i.e. "declare it and install it" to an operator who has already done
Expand Down Expand Up @@ -264,7 +286,7 @@ describe('serve — the posture description an operator reads names the declarat
expect(verdict.ok, 'the gate accepted a value that is not a posture').toBe(false);
if (verdict.ok) return;
expect(lines(verdict.fatal)).toContain(
` • set OS_TENANCY_POSTURE=isolated — organization wall + the enterprise ${PKG} runtime `
` • set OS_TENANCY_POSTURE=isolated — organization wall + the ${PKG} runtime `
+ "(the legacy spelling 'multi' is accepted and normalizes to this)",
);
});
Expand Down Expand Up @@ -301,9 +323,9 @@ describe('#12151 CONTROL — these pins can say no', () => {
// The exact regression the card names: interpolating into a template is
// where a stray space or a lost backtick hides. If this instrument could
// not tell the two apart, every assertion above would be decorative.
const expected = ` • add ${PKG} (the enterprise multi-org runtime) to THIS APP`;
expect(` • add ${PKG}(the enterprise multi-org runtime) to THIS APP`).not.toBe(expected);
expect(` • add ${PKG} (the enterprise multi-org runtime) to THIS APP`).not.toBe(expected);
const expected = ` • add ${PKG} (the multi-org runtime) to THIS APP`;
expect(` • add ${PKG}(the multi-org runtime) to THIS APP`).not.toBe(expected);
expect(` • add ${PKG} (the multi-org runtime) to THIS APP`).not.toBe(expected);
expect(lines(remedyUndeclared())[0]).toBe(expected);
});

Expand Down
64 changes: 38 additions & 26 deletions packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -790,10 +790,12 @@ function anchorServedApp(configArg: string): { configPath: string; configExists:
* Node ESM resolves a bare `import(pkg)` against the IMPORTER's own realpath.
* The CLI is reached through a workspace/`link:` dependency, so that realpath is
* inside the FRAMEWORK workspace: a bare import can only see what the framework
* itself installed. A package supplied by the app being served — a cloud-private
* one such as `@objectstack/organizations`, a distribution one such as
* `@objectstack/service-cluster`, or anything a customer installs into their own
* project — is invisible to it no matter what the host app declares.
* itself installed. A package supplied by the app being served — an app-declared
* one such as `@objectstack/organizations` (open core since ADR-0132; a
* commercial deployment resolves that same name to its own private build), a
* distribution one such as `@objectstack/service-cluster`, or anything a customer
* installs into their own project — is invisible to it no matter what the host
* app declares.
*
* #4719: "resolve from the host root" means "resolve what the host root
* DECLARES". The host lookup was a CJS require, CJS honours NODE_PATH, and the
Expand All @@ -815,7 +817,7 @@ function anchorServedApp(configArg: string): { configPath: string; configExists:
*
* • cloud#1013 — the binding sat below the AUTH block, so the enterprise
* organizations load resolved in the framework workspace, never found the
* cloud-private package, and every walled-posture deployment hit the
* then-cloud-private package, and every walled-posture deployment hit the
* ADR-0093 D5 fail-fast and exited 1.
* • #10645 — the binding sat below the CLUSTER block, so `serve` could not load
* an app-declared `@objectstack/service-cluster*` at all: on the published EE
Expand Down Expand Up @@ -3677,16 +3679,23 @@ export default class Serve extends Command {
}
}

// Pair: OrganizationsPlugin (multi-org, ENTERPRISE) — must register
// BEFORE SecurityPlugin. The multi-org runtime (`organization_id`
// Pair: OrganizationsPlugin (multi-org) — must register BEFORE
// SecurityPlugin. The multi-org runtime (`organization_id`
// auto-stamp, per-org seed replay, multi-org default-org bootstrap)
// lives in the closed-source `@objectstack/organizations` package
// (ADR-0105 D12; it registers the historical `org-scoping` service
// SecurityPlugin probes at start() to keep vs strip the wildcard
// `tenant_isolation` RLS — so registration order matters). Without
// it, deployments are single-org: the open member-management
// lives in the `@objectstack/organizations` package (ADR-0105 D12
// as amended by ADR-0132; it registers the historical `org-scoping`
// service SecurityPlugin probes at start() to keep vs strip the
// wildcard `tenant_isolation` RLS — so registration order matters).
// Without it, deployments are single-org: the open member-management
// basics (plugin-auth's default-org bootstrap + better-auth
// invitations) still work.
// ⚠️ ONE NAME, TWO PACKAGES (ADR-0132 D3): the framework publishes
// an Apache-2.0 package of that name (`packages/plugins/organizations`),
// and a commercial deployment resolves the same name to a private,
// licence-gated subclass of it through its own `workspace:*`
// declaration. `serve` does not choose between them and must not
// try — the host app's own manifest decides, which is exactly what
// the host-anchored importer below reads (#4719).
// [ADR-0105 D1] Key off the resolved POSTURE, not the legacy boolean.
// Both walled postures (`group` and `isolated`) need this package:
// gating on `OS_MULTI_ORG_ENABLED` alone would let
Expand Down Expand Up @@ -3724,18 +3733,20 @@ export default class Serve extends Command {
// ── Stage 1: import. Failure here = the package is ABSENT. ──
try {
// Resolve from the HOST APP (cloud#1013). This package is
// cloud-private: it is installed in the served app's
// node_modules, never in the framework workspace the CLI's own
// realpath points at, so a bare import here could never find it
// — `objectstack serve` failed the fail-fast below on EVERY
// self-hosted walled-posture deployment, and the only way past
// it was OS_ALLOW_DEGRADED_TENANCY=1, i.e. exactly the unwalled
// state D5 exists to prevent. The host app declares the package;
// this resolves it from there.
// APP-DECLARED (ADR-0132 D3 — which build of the name a
// deployment gets is the host manifest's call, not `serve`'s):
// it is installed in the served app's node_modules, never in
// the framework workspace the CLI's own realpath points at, so a
// bare import here could never find it — `objectstack serve`
// failed the fail-fast below on EVERY self-hosted walled-posture
// deployment, and the only way past it was
// OS_ALLOW_DEGRADED_TENANCY=1, i.e. exactly the unwalled state D5
// exists to prevent. The host app declares the package; this
// resolves it from there.
orgMod = await importFromHost(organizationsPkg);
} catch (orgErr) {
// ADR-0093 D5 — degraded tenancy fails fast. Multi-org was
// requested but the enterprise package can't provide tenant
// requested but the organizations package can't provide tenant
// isolation: `tenant_isolation` RLS would be stripped and every
// org boundary inert. A deployment that asked for isolation must
// NOT serve traffic pretending to have it (ADR-0049 at the
Expand Down Expand Up @@ -4076,7 +4087,7 @@ export default class Serve extends Command {
//
// #4719 — this used to be a local re-implementation of that read. It was
// right, and it was the ONLY place in the boot path that asked the question
// the right way: the enterprise organizations load two blocks up asked
// the right way: the organizations load two blocks up asked
// "does it resolve", which a hoisted store answered yes to regardless. Both
// now go through the one owner in `@objectstack/types/node`, so "declared"
// cannot mean two different things in one file (Prime Directive #12).
Expand Down Expand Up @@ -5441,14 +5452,15 @@ export function formatOrganizationsInstallRemedy(
' the remedy is in the package, and the cause below is the authority on what it\n' +
' has to publish — or\n';
}
return ` • add ${pkg} (the enterprise multi-org runtime) to THIS APP\n` +
return ` • add ${pkg} (the multi-org runtime) to THIS APP\n` +
" — declare it in the app's package.json and install; the CLI resolves it from the\n" +
' app, not from the framework it is linked out of. Being merely reachable\n' +
' through NODE_PATH / a hoisted workspace store is deliberately not enough\n' +
' (#4719) — that made this wall depend on how the process was launched.\n' +
' NOTE: this runtime is closed-source and is NOT on the public npm registry —\n' +
' it is distributed with an enterprise / cloud subscription. Without one this\n' +
' bullet is not followable, and one of the two below is your path — or\n';
' NOTE: this runtime is Apache-2.0 and published on the public npm registry\n' +
' (ADR-0132), so this bullet is followable on any install — no subscription.\n' +
' A commercial deployment resolves the same package name to its own private,\n' +
' licence-gated build; the manifest that declares it decides which — or\n';
}

/**
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ export default class Verify extends Command {
default: false,
}),
'multi-tenant': Flags.boolean({
description: 'Boot org-scoped (register the enterprise @objectstack/organizations plugin) so tenant-isolation RLS policies apply (also honors a walled $OS_TENANCY_POSTURE, and the legacy $OS_MULTI_ORG_ENABLED it falls back to)',
description: 'Boot org-scoped (register the @objectstack/organizations plugin) so tenant-isolation RLS policies apply (also honors a walled $OS_TENANCY_POSTURE, and the legacy $OS_MULTI_ORG_ENABLED it falls back to)',
default: false,
}),
json: Flags.boolean({ description: 'Emit the structured report as JSON', default: false }),
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/utils/tenancy-posture-hints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,6 @@ export const TENANCY_POSTURE_FIX_HINTS: Readonly<Record<string, string>> = {
single: 'one organization, no organization wall — the default',
group: 'organization wall enforced by the open engine, one shared database',
isolated:
`organization wall + the enterprise ${ORGANIZATIONS_RUNTIME_PKG} runtime `
`organization wall + the ${ORGANIZATIONS_RUNTIME_PKG} runtime `
+ "(the legacy spelling 'multi' is accepted and normalizes to this)",
};
13 changes: 8 additions & 5 deletions packages/cli/test/serve-organizations-mount-failure.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

/**
* #4818 — `os serve` must tell an operator WHICH of two different things went
* wrong with the enterprise multi-org runtime, over the REAL CLI process.
* wrong with the multi-org runtime, over the REAL CLI process.
*
* The defect: `importFromHost('@objectstack/organizations')` and
* `kernel.use(new mod.OrganizationsPlugin())` shared a single `try`, so an
Expand All @@ -27,10 +27,13 @@
* `serve-organizations-host-resolution.e2e.test.ts`): every other test of the
* walled postures hands the plugin in as `extraPlugins` or mocks the module,
* which bypasses the CLI's own load/mount sequence — the only thing under test
* here. The fixtures stand in for the closed-source enterprise package: one app
* simply does not ship it, another ships a version whose plugin throws on
* construction (the shape cloud#1020 gave its license gate). What is asserted
* is the CLI's CLASSIFICATION and its message, not any enterprise semantics.
* here. The fixtures stand in for a mounted multi-org runtime: one app simply
* does not ship it, another ships a version whose plugin throws on construction
* — which is specifically the COMMERCIAL package's shape, since ADR-0132 D2
* leaves construction-time licence refusal to the private subclass and the open
* `packages/plugins/organizations` carries no licence check of any kind (the
* shape cloud#1020 gave its license gate). What is asserted is the CLI's
* CLASSIFICATION and its message, not any runtime's semantics.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
Expand Down
Loading
Loading