Skip to content

Bring single-database multi-organization isolation back to the open core — the wall is already open, only the org-scoping switch is closed (~1350 of 1660 lines move; the licence gate stays) #16130

Closed

Description

@hotlong

Chartered by the maintainer, 2026-09-06, live chat, verbatim: 「感觉 单库多组织隔离是开源基本需求,如果迁移回开源项目成本有多大」 then 「直接立专题卡派发处理吧」. ⇒ The cost question is answered below by measurement; this card is the work.

The finding that makes this cheap

The wall is already open. What is closed is the switch.

Layer 0 (plugin-security/src/tenant-layer.ts, computeTenantLayer0Filter), the three postures in packages/spec, the API-key and session membership guards, ADR-0105's group posture — all open source. Three cross-organization defects were found and fixed in the open tree this week (#15256, #15409, cloud#1987's framework half). What an open-source deployment cannot do is turn the wall on, because the org-scoping service has exactly one registrar anywhere: the cloud-private @objectstack/organizations.

⭐ And it went the other way once, deliberately: organizations-plugin.ts's own docblock says it was "migrated verbatim from the open plugin-org-scoping" (ADR-0081 D2). The open core still consumes it unchanged — getService('org-scoping') and requiresService: 'org-scoping' nav gates — and the service name was kept on purpose so those anchors would not break. ⇒ No consumer-side change is needed.

Measured on cloud origin/main, 2026-09-06

8 non-test source files, 1660 lines, 8 test files. The licence headers already draw the boundary:

file lines header
organizations-plugin.ts 508 proprietary
membership-policy-gate.ts 416 proprietary
multi-org-license-gate.ts 306 proprietary
index.ts 107 proprietary
manifest.ts 28 proprietary
claim-orphan-org-rows.ts 135 Apache-2.0
claim-org-seed-ownership.ts 116 Apache-2.0
ensure-default-organization.ts 44 Apache-2.0

Coupling to cloud is exactly two points, both entitlement: multi-org-license-gate.ts imports security-enterprise's license/entitlement.ts; index.ts:41 carries a bare import '@objectstack/security-enterprise' to mount the multi-node authorization gate as a side effect (cloud#1827). Everything else imports framework packages only — 4 of the package's 5 dependencies are link: into this repo.

⇒ ~1350 lines move; 306 stay.

ADR-0081 already split this once: D1's open member-management basics landed here (plugin-auth's 415-line ensureDefaultOrganization), leaving cloud a 44-line multi-org wrapper.

⛔ Hard boundaries — these do NOT move

  1. multi-org-license-gate.ts stays in cloud. It is the entitlement, and it exists because the gate must be answered by the package that implements multi-org, at construction — cloud#1020 records the previous shape (an env rewrite in a host config) failing exactly when ADR-0105 D1 made the posture authoritative. ⛔ Do not move it, do not reimplement it here, and ⛔ do not leave a hook that a host could use to re-add one.
  2. The multi-node gate carrier stays. MULTI_NODE_GATE_CARRIER_PACKAGES (fix(service-cluster,cli): multi-node gate fails closed when unregistered, and mounts on every boot route #14114) names both packages precisely so a deployment declares whichever it needs. Whatever remains in cloud must keep carrying it; the open package must not acquire it.
  3. ⛔ The open package must not gain a licence check of any kind. If enforcement is wanted, it is cloud's, on cloud's side of the split.

⚠️ This reverses a recorded decision — the record moves with the code

ADR-0081 D2 put the multi-org machinery in enterprise on purpose. Landing this without amending it leaves the tree contradicting a live ADR. The open mirror of that record is #14508 (pm:queue, unstarted). ⇒ The ADR amendment is part of this work, not a follow-up: docs/adr/** is a governed surface — draft PR, human merge.

Phase 1 — classify before moving. ⛔ Do not open a code PR first.

Read all 1660 lines and produce, on this card, a line-level disposition for every one: move as-is / move with changes (say which) / stays in cloud / deleted. Two files decide whether the estimate above survives:

  • organizations-plugin.ts (508) — what it registers beyond the service, and what of that is cloud-shaped.
  • membership-policy-gate.ts (416) — a boot-time membership-policy assertion, and the one file whose depth this seat did not measure. ⚠️ If it encodes enterprise-only assumptions, the estimate is wrong and this card's shape changes.

⛔ Stop and report rather than proceeding if: the classification finds a third coupling point; membership-policy-gate.ts cannot be separated from entitlement; or the open package would need any part of security-enterprise.

Phase 2 — the move, once phase 1 is accepted

New open package (name it after the classification — plugin-org-scoping is the historical one and the anchors expect the service name org-scoping, not a package name). Cloud keeps the gate and imports the open package. Both sides' tests move with their code. ⛔ The service name org-scoping does not change — the open core's nav gates and getService calls are anchored on it.

Acceptance: an open-source-only install with OS_TENANCY_POSTURE=isolated boots with the wall ACTIVE and enforces it — the same matrix the three cross-organization repairs pinned (an ex-member's API key and session are refused; a member reads only its own organization). ⚠️ That matrix is the point of the whole card: today an open install cannot run it at all.

Refs: cloud packages/organizations · cloud ADR-0081 D1/D2 · #14508 (the open mirror) · cloud#1020 (why the gate lives where it lives) · #14114 / cloud#1827 (multi-node carrier) · ADR-0105 D1 · #15256 / #15409 (the wall's open-source repairs).

Activity

  1. hotlong commented on Sep 6, 2026

    @hotlong
    ContributorAuthor

    Claim: PM loop — maintainer direct-dispatch channel (2026-08-10 standing authorization).
    Provenance: maintainer, 2026-09-06, live chat with this session — 「感觉 单库多组织隔离是开源基本需求,如果迁移回开源项目成本有多大」 then 「直接立专题卡派发处理吧」.
    Session: 6679d191-11f4-465b-b322-0e0409d76793
    Branch: none for phase 1 — the deliverable is a line-level classification posted on this card. ⛔ No code PR opens until that classification is accepted.
    Worktree: sibling level for the cloud side (/Users/zhuangjianguo/Documents/GitHub/cloud-issue-16130), ⛔ never under .claude/worktrees — cloud resolves dependencies by relative path.
    Domain: domain:services provisional (the code lands as a plugin); ⚠️ phase 2 also touches docs/adr/**, a governed surface — draft PR, human merge, and it is part of this work rather than a follow-up.
    File surface, phase 1: read-only over cloud/packages/organizations/** and the open consumers. ⛔ Nothing is edited in phase 1.
    Container & model: L, mode:subagent, model: opus — a two-repo move across a commercial boundary, where the expensive mistake is moving one line too many.
    Clause-②: yes, expected at phase 2 — a new open package registering a service the open core already consumes widens the published surface. Re-judged from the diff at review.
    Serial constraints cleared: no open PR touches cloud/packages/organizations (REST search 2026-09-06); #14508 (the ADR-0081 open mirror) is pm:queue, unstarted and unassigned — ⚠️ its writer and this card's phase 2 must not both write that record; whoever moves second adopts the other's text.

  2. self-assigned this
    on Sep 6, 2026
  3. hotlong commented on Sep 6, 2026

    @hotlong
    ContributorAuthor

    os-dev-report

    Phase 1 — line-level disposition of all 1660 lines. No code moved, no branch, no PR.

    Read against origin/main in both repos (cloud daaac081, objectstack 932acc3d). ⚠️ The local cloud checkout at /Users/zhuangjianguo/Documents/GitHub/cloud is 75 commits behind origin/main; every line number and quote below is read through git show origin/main:PATH, never the working tree.

    Verdict: the estimate survives. ~1350 → 1300 move, 342 stay, 18 deleted.

    None of the four stop conditions fired. No third coupling point, membership-policy-gate.ts is cleanly separable from entitlement, the open package needs nothing from security-enterprise, and the movable size is within 3.7% of the card's figure.

    Which measurements held

    card's measurement verdict
    8 non-test files, 1660 lines ✅ held — 508+416+306+107+28+135+116+44 = 1660 exactly
    per-file licence headers as tabulated ✅ held — all 8 verified line 1 by line 1
    coupling is exactly two points, both entitlement ✅ held at the import level, and mechanically: a sweep of every import/from across all 8 files finds @objectstack/security-enterprise at exactly multi-org-license-gate.ts:51-56 and index.ts:41. ⚠️ Refined — see "the seam the count misses" below
    4 of 5 dependencies are link: into the framework ✅ held — core, plugin-auth, spec, types are link:; only security-enterprise is workspace:*
    organizations-plugin.ts migrated verbatim from open plugin-org-scoping; open core consumes it unchanged ✅ held — docblock line 64; open core's getService('org-scoping') at plugin-auth/src/auth-plugin.ts:589,601, plugin-security/src/security-plugin.ts:1250, deployment-org-scoping-entitlement.ts, and requiresService: 'org-scoping' at platform-objects/src/apps/setup-nav.contributions.ts:86, account.app.ts:144, dashboards/system_overview.dashboard.ts:69
    ~1350 lines move, 306 stay ⚠️ corrected to 1300 / 342 — the 36-line delta is index.ts's two cloud blocks, which a whole-file count could not see

    The line-level disposition

    file lines move as-is move with changes deleted stays in cloud
    organizations-plugin.ts 508 480 10 18 0
    membership-policy-gate.ts 416 407 9 0 0
    multi-org-license-gate.ts 306 0 0 0 306
    index.ts 107 59 12 0 36
    manifest.ts 28 24 4 0 0
    claim-orphan-org-rows.ts 135 135 0 0 0
    claim-org-seed-ownership.ts 116 116 0 0 0
    ensure-default-organization.ts 44 44 0 0 0
    total 1660 1265 35 18 342

    Moving = 1265 + 35 = 1300.

    The three Apache-2.0 files — 295 lines, move as-is, zero edits

    claim-orphan-org-rows.ts (135), claim-org-seed-ownership.ts (116), ensure-default-organization.ts (44). Their only non-local imports are type ServiceObject from @objectstack/spec/data and ensureDefaultOrganization / EnsureDefaultOrganizationResult from @objectstack/plugin-auth — both verified present on objectstack origin/main (packages/spec/src/data/object.zod.ts:2870, packages/plugins/plugin-auth/src/ensure-default-organization.ts:276). The headers already say Apache-2.0. Nothing in these three needs touching.

    organizations-plugin.ts (508) — 480 as-is, 10 changed, 18 deleted

    What it registers, beyond the service. Answering the card's question directly: init() registers exactly two things — ctx.registerService('org-scoping', this) (line 183) and one manifest registration of an empty object list (organizationsObjects = [], lines 185-190). start() adds two ObjectQL middlewares (insert auto-stamp at 257-291; per-org seed pipeline at 294-406), one kernel:bootstrapped hook (the membership gate, 225-232), one kernel:ready hook plus a third middleware for the default-org bootstrap (409-450).

    How much of that is cloud-shaped: none of it. The complete set of services this file touches is org-scoping, manifest, objectql, metadata, seed-datasets, seed-replayer — every one an open framework service. A sweep for sys_environment, kernel-manager, control-plane, environment-registry, Cloudflare/D1/turso, service-cloud and objectos across all 8 files returns only comment prose citing cloud files for provenance — zero code references. The only env vars named anywhere in the moving files are OS_TENANCY_POSTURE, OS_AUTH_MEMBERSHIP_POLICY and OS_ALLOW_DEGRADED_TENANCY, all open-framework knobs. OS_LICENSE_KEY appears only in multi-org-license-gate.ts.

    Deleted — 18 lines, the entitlement excision:

    • line 8 — import { assertMultiOrgEntitled } from './multi-org-license-gate.js';
    • lines 153-169 — the LICENSE gate comment block and its assertMultiOrgEntitled(); call, the first statement of the constructor. The constructor itself survives (152, 170-173) to set this.opts.

    Move with changes — 10 lines, all prose, two of them boundary-critical:

    • line 1 — proprietary header → Apache-2.0.
    • lines 63-65 — "the enterprise multi-organization runtime (ADR-0081 D2; migrated verbatim from the open plugin-org-scoping, which the framework no longer ships)". The parenthetical inverts on this move; it becomes the record of the round trip.
    • ⛔ lines 128-130 — "that is a PACKAGING decision, and it belongs here, in the commercial runtime, rather than hard-coded in open core." This is ADR-0105 D12's argument for putting supportedPostures in the closed package, and moving the file contradicts it in place. The declaration itself (line 145, ['group', 'isolated']) moves as-is and is correct for an open install — an open deployment is entitled to both walled postures. Only the justification changes, and it should say so explicitly rather than be quietly deleted.
    • ⛔ lines 134-136 — "narrowing the boundary later — selling group as a higher tier than isolated, gating it behind a licence flag, running a trial — becomes a one-line edit HERE." Left verbatim in an open package this reads as an invitation to add exactly the licence check hard boundary 3 forbids. Must be reworded, not merely carried over.

    Deliberately kept verbatim:

    • line 117 name = 'com.objectstack.organizations' — anchored by cloud service-cloud/src/control-plane-preset.ts:691 (lazyPlugin('com.objectstack.organizations', ...)) and by open fixtures at packages/types/src/node.test.ts:176,552, packages/verify/src/harness.host-resolution.test.ts:44, packages/qa/dogfood/test/enterprise-organizations.test.ts:98. The plugin id is a second anchor alongside the service name; changing it is a separate decision with its own blast radius.
    • lines 177-183 — the "service name stays org-scoping ON PURPOSE" comment and the registration. Boundary 4, satisfied by doing nothing.
    • lines 52-59, 378-405 — the cloud#1345 no-donor-clone invariant and its ⛔ markers. These are the most safety-critical comments in the file and they travel unchanged.

    membership-policy-gate.ts (416) — the file whose depth was unmeasured

    It separates from entitlement completely. Its only two imports are isMembershipPolicy, MEMBERSHIP_POLICIES from @objectstack/plugin-auth (verified at reconcile-membership.ts:63,68) and resolveTenancyPosture from @objectstack/types (verified at types/src/env.ts:145). Zero references to security-enterprise, to multi-org-license-gate.ts, or to any entitlement symbol. The header states the independence as a designed non-feature, lines 61-64: no env escape hatch, "and neither does a licence". It adjudicates a declaration, not an authorization — it reads the settings service's own source field for auth.membership_policy and refuses default. Its services are auth and settings, both open.

    No enterprise-only assumption survives inspection. What looks enterprise-only is the refuse-rather-than-warn rationale at lines 29-32 — the 2026-08-04 ruling that "EE has NO deployed customers — zero breakage surface". That argument does not transfer to open, but its conclusion still holds for a different and verifiable reason: today no open install can reach a walled posture at all. serve.ts:3700 computes multiTenant = tenancyPosture !== 'single', so both group and isolated require the cloud package; absent it, serve.ts:3747 prints the ADR-0093 D5 fatal and process.exit(1), and the only way past is OS_ALLOW_DEGRADED_TENANCY=1, which leaves orgMod undefined so this gate never runs either. The breakage surface at the moment of the move is genuinely zero — it just needs to be argued from that fact instead of from EE's customer count.

    Move with changes — 9 prose lines: line 1 (header), line 8 ("an EE deployment" → "a deployment"), lines 29-32 (re-argue zero-breakage from the boot-refusal fact above), lines 55 and 111 (citations of cloud-private paths service-cloud and packages/objectos-runtime/src/artifact-kernel-factory.ts, dangling for an open reader), and line 337.

    Line 337 deserves its own note. The fatal message says, at 336-337:

    This is NOT a licensing failure and NOT a missing package: the multi-org runtime is present, licensed, and about to enforce the wall.

    Dropping , licensed, from line 337 is the whole edit. It is test-safe: the pin at membership-policy-gate.test.ts:368 matches /NOT a licensing failure/, which lives on line 336 and is untouched. Keeping the sentence would not breach boundary 3 — prose is not a check — but it would be false on an open-only install.

    The other 407 lines move as-is, including the whole 100-line message builder whose text is pinned by 18 assertions in the suite.

    index.ts (107) — the file that genuinely splits three ways

    • Stays in cloud — lines 26-41 (16): the MULTI_NODE_GATE_CARRIER_PACKAGES comment and the bare import '@objectstack/security-enterprise';. Boundary 2 — the open package must not acquire this. Verified against service-cluster/src/multi-node-gate-mount.ts:70-73, which still names both packages.
    • Stays in cloud — lines 88-107 (20): the licence-gate export block, all twelve multi-org-license-gate.js symbols. Consumed by apps/objectos-ee/objectstack.config.ts:98 (assertWalledPostureEntitled, isMultiOrgLicenseError), both EE showcase configs (grantDevelopmentMultiOrgEntitlement), and service-cloud/src/control-plane-organizations.ts:40 (grantFirstPartyMultiOrgEntitlement). Cloud's package must keep exporting these under the same specifier.
    • Move with changes — 12 lines: line 1 (header); lines 4-6 (the "enterprise / closed-source successor" framing); lines 17-23 ("The open framework mounts this package best-effort by name when OS_MULTI_ORG_ENABLED=true" — false for the open package, which serve will reach differently, see phase 2 below); line 45 (the transitional-alias comment, whose migration direction inverts — the OrgScopingPlugin alias itself at 46-47 is worth keeping and moves as-is).
    • Move as-is — 59 lines: the plugin, claim-helper, ensure and manifest re-exports (42-44, 46-66) and the whole membership-gate export block (67-87).

    manifest.ts (28) — 24 as-is, 4 changed

    Changed: line 1 (header), line 4 (package name in the docblock), line 23 (name: 'Organizations (Enterprise)'), line 25 (description opens "Enterprise multi-organization runtime").

    Two lines that look cloud-shaped and are not, both move as-is:

    • line 22 defaultDatasource: 'cloud' — this is the open framework's own convention for scope: 'system' packages, documented at objectstack packages/spec/src/kernel/manifest.zod.ts:315 ("Packages with this scope should also set defaultDatasource: 'cloud'") and used by open packages/metadata/src/plugin.ts:392. It names a datasource, not this repo.
    • line 9 ORGANIZATIONS_PLUGIN_ID — see the plugin-id anchors above.

    multi-org-license-gate.ts (306) — stays in cloud, in full

    All 306 lines. Boundary 1, no line-level analysis needed beyond confirming nothing else in the package depends on it except the two seams already named.

    ⚠️ The seam the "exactly two" count misses

    "Coupling to cloud is exactly two" is true and I verified it mechanically — but it counts cross-package imports, and the move has a third touch point that lives inside the package: organizations-plugin.ts imports assertMultiOrgEntitled (line 8) and calls it as the first statement of its constructor (line 168). The 508-line file that moves is wired to the 306-line file that stays.

    This is not a stop condition — it is not a dependency on security-enterprise, and it is exactly where the split is supposed to happen. But it has one consequence the card should carry into phase 2: organizations-plugin.ts cannot move as-is, and cloud must re-establish a construction-time refusal without the open package offering a hook for it (boundary 1's "do not leave a hook a host could use to re-add one" rules out any injectable-assertion parameter).

    The shape that satisfies this without any open-side seam: cloud's package keeps multi-org-license-gate.ts and exports its own OrganizationsPlugin that extends the open class and calls assertMultiOrgEntitled() in its own constructor before super()-side work. Cloud-side code calling a cloud-side gate; the open package gains nothing and offers nothing. It also preserves the two existing consumers byte-for-byte: serve.ts:3782's new orgMod.OrganizationsPlugin() and its two-stage classifier, which deliberately keys on which stage threw rather than the error's shape, and artifact-kernel-factory.ts:862-866's new organizations.OrganizationsPlugin() plus isMultiOrgLicenseError. Both keep working untouched.

    Phase 2 on the open side — what an open-only isolated boot would take

    ⛔ Not built. Scope only. The card budgets the move; it does not budget this, and this is the larger half.

    1. packages/cli/src/utils/tenancy-posture-hints.ts:117 — ORGANIZATIONS_RUNTIME_PKG is one hard-coded spelling, and this file's own header records a maintainer ruling (2026-08-27, The two ⛔ notes forbidding a single-sourced ORGANIZATIONS_RUNTIME_PKG name a constraint that no longer exists — decide whether to consume the resolver hop or restate why not #12579) that the spelling is single-sourced here, may not be read from the spec roster, and may not be imported from the serve command. Teaching serve a second, open candidate is therefore a governed edit at a spot with a standing ruling on it.
    2. packages/cli/src/commands/serve.ts:3700-3800 — the two-stage import/mount. Stage 1 resolves through importFromHost because the package is cloud-private; an open registrar lives in the framework workspace and resolves differently. And the ADR-0093 D5 semantics change meaning: once an open registrar exists, "the multi-org runtime is absent" can no longer be the normal state for isolated, so formatOrganizationsAbsentFatal, the install remedy text and OS_ALLOW_DEGRADED_TENANCY's scope all need re-deriving.
    3. packages/spec/src/kernel/platform-capabilities.ts:237-243 — the roster row for @objectstack/organizations is edition: 'enterprise' and its note says serve loads it off the tenancy posture. That note becomes wrong. A new open package likely takes no row (the roster is for out-of-repo plugins[]-wired runtimes), but the drift test in serve-capability-vocabulary.test.ts reads both the row and MULTI_NODE_GATE_CARRIER_PACKAGES, whose header describes both carriers as "enterprise runtimes the spec roster declares".
    4. Pin tests holding the spelling or the prose, all of which redden together: serve-organizations-message-spelling.test.ts, doctor-organizations-message-spelling.test.ts, serve-cluster-host-resolution.test.ts (a named-literal sweep that keeps '@objectstack/organizations' in an expected set), serve-capability-vocabulary.test.ts, serve-organizations-host-resolution.e2e.test.ts, serve-organizations-mount-failure.e2e.test.ts, packages/types/src/node.test.ts, packages/verify/src/harness.host-resolution.test.ts.
    5. No change at all to the consumers, which is the card's central claim and it holds: plugin-auth/src/auth-plugin.ts:589,601, plugin-security/src/security-plugin.ts:1250, plugin-security/src/deployment-org-scoping-entitlement.ts, and the three requiresService: 'org-scoping' nav gates all probe by service name. ℹ️ One thing to carry: OrgScopingEntitlement has grown two keys beyond supportedPostures — platformGlobalObjects and suppressUnboundedOrgAdminGrant — both fail-closed when absent, so the moving plugin (which declares neither) is unaffected, but the open package now owns that declaration surface.
    6. MULTI_NODE_GATE_CARRIER_PACKAGES needs no edit — both names stay, boundary 2 satisfied by inaction.
    7. ⚠️ The acceptance matrix has no open home, and this is the unbudgeted item. The card's acceptance — an ex-member's API key and session refused, a member reads only its own organization — is exercised today only by cloud's apps/ee-tenant-crm-showcase/test/isolated-posture.dogfood.test.ts and apps/ee-group-showcase/test/*.dogfood.test.ts, both of which boot the real runtime after calling grantDevelopmentMultiOrgEntitlement. On the open side the nearest thing is packages/qa/dogfood/test/enterprise-organizations.test.ts, which tests only the availability probe using stand-in packages on disk — it never raises a wall. A grep for a walled OS_TENANCY_POSTURE across open examples/, apps/ and packages/qa/ returns one unrelated dogfood file. An open walled-posture acceptance app has to be built from nothing, and until it exists the card's own acceptance criterion cannot be evaluated in the open repo.
    8. ADR-0081 D2 amendment — as the card says, part of this work, governed surface, human merge. Open mirror ADR (mirror of cloud ADR-0081): Organization management — the open basics half (D1 invitations stay open, D3 org record page, D4 org-scoped sys_member), recorded in this repo with Provenance #14508.

    Tests (not part of the 1660, stated for phase 2 planning)

    8 test files, 2081 lines. Every entitlement reference in the five moving-side suites is a test-setup grant that simply deletes on the open side — a one-line grantDevelopmentMultiOrgEntitlement(...) plus its import, at organizations-plugin.test.ts:5,11, walled-default-org-self-registrant.pin.test.ts:40,43, org-creation-no-cross-org-copy.test.ts:35,43, membership-policy-gate.test.ts:24-26,32. There is nothing to grant when there is no gate. The single exception is membership-policy-gate.test.ts:367, expect(isMultiOrgLicenseError(err)).toBe(false) — an assertion discriminating this refusal from the licensing one, which has no counterpart in an open tree and drops. multi-org-license-gate.test.ts (344) and multi-node-gate-carrier.test.ts (37) stay in cloud with their subjects.

    Nothing was written

    ⛔ No file edited in either repo, no branch, no PR. Both repos read through origin/main only; the cloud working tree was never used and never touched.


    Generated by Claude Code

  4. hotlong commented on Sep 6, 2026

    @hotlong
    ContributorAuthor

    PM answers the three open questions — A, A, A, with one correction of scope on the third

    Phase 1 is accepted. The estimate survived at 1300 move / 342 stay / 18 delete against the card's ~1350, none of the four stop conditions fired, and the classification was taken through git show origin/main:PATH on both repos rather than a working tree that is 75 commits behind — the discipline that makes the readings usable.

    Q1 — the constructor's assertMultiOrgEntitled() → A: cloud subclasses

    Cloud's package exports its own OrganizationsPlugin extending the open class and calls the gate in its own constructor. It satisfies hard boundary 1 exactly — cloud code, cloud gate, and the open package neither gains nor offers a hook — and it is the only option that leaves serve.ts's two-stage classifier untouched, since that classifier keys on which stage threw rather than on the error's shape. ⛔ B changes the new orgMod.OrganizationsPlugin() call shape that two call sites depend on for no gain; ⛔ C is the forbidden hook.

    Q2 — the ADR-0105 D12 paragraph → A: reword, and the amendment carries D12 too

    ⭐ And the seat's separate observation is upgraded to a requirement: lines 134-136 — "gating it behind a licence flag … becomes a one-line edit HERE" — ⛔ must be reworded regardless of what happens to 128-130. Left verbatim in an open file, that sentence is a written invitation to add the licence check hard boundary 3 forbids, sitting in the exact file where it would go. Leaving it would be worse than the drift it describes.

    Q3 — the acceptance matrix → A: its own card, but it is far smaller than estimated

    ⚠️ The premise is corrected by measurement. The report says the matrix "has no open home" and that building one is "unbudgeted work larger than the move itself". Measured on objectstack origin/main: the matrix already has an open home — packages/rest/src/single-kernel-isolated-api-key-matrix.test.ts and single-kernel-isolated-session-org-claim-matrix.test.ts, the two suites the cross-organization repairs pinned, both driving a live isolated posture at REST level.

    What they do not do is produce that posture. Both raise it with a hand-written stub:

    const tenancyServiceProvider = async () => ({ posture: 'isolated' });

    ⇒ The open tree already proves the wall behaves correctly given a posture. What nothing open proves is that anything can produce one — which is precisely what this card moves. So the acceptance card is not "build a walled showcase app"; it is "run the existing open matrices against the real open org-scoping registrar instead of the stub". Filed separately as decided, and ⛔ it stays a blocking acceptance: phase 2's PR ⛔ must not claim the card's acceptance criterion is met, and must name that card as where it is discharged.

    ⇒ ⛔ C (porting cloud's showcase suites) is not needed for the acceptance and should not be scoped into it.

    Dispatching phase 2 on this basis.

  5. hotlong commented on Sep 6, 2026

    @hotlong
    ContributorAuthor

    The goal, stated by the maintainer — and measured against what is already open

    Maintainer, 2026-09-06, live chat, verbatim:

    迁移之后的效果应该是开源版就可以把元数据应用使用单库多租户的方式运行。

    ⇒ The bar is not "the org-scoping service can be registered". It is an open-source-only install runs a metadata application in single-database multi-tenant mode. Measured on origin/main against that bar, and the news is good — everything except the switch is already open:

    piece where it is
    the wall open — plugin-security/src/tenant-layer.ts, the three postures in packages/spec
    the posture knob open — resolveTenancyPosture() in packages/types
    organizations, invitations as objects open — platform-objects/src/identity/sys-organization.object.ts, sys-invitation.object.ts
    org CRUD / membership / invite endpoints open — better-auth's organization plugin is mounted in plugin-auth/src/auth-manager.ts:2795, and createOrganization resolves in open plugin-auth (ADR-0081 D1: the open member-management basics own it)
    the Setup surface for it open — requiresService: 'org-scoping' gates in setup-nav.contributions.ts, account.app.ts, system_overview.dashboard.ts; they appear the moment the service registers
    the org-scoping registrar ⛔ cloud-private — the one missing piece

    ⇒ The maintainer's stated effect is delivered by this card, and by this card alone. ⛔ No second migration is hiding behind it.

    ⭐ And the sharpest way to say what changes

    Today an open-source install that sets OS_TENANCY_POSTURE=isolated cannot enforce it. Its only way forward is OS_ALLOW_DEGRADED_TENANCY=1 — the wall configured but not enforced, which is the precise shape this session spent the day closing three cross-organization defects against, and the shape ADR-0131 D9 refuses.

    ⇒ The acceptance on #16137 is therefore stated at its sharpest, and phase 2 should be read against it: an open-only install, OS_TENANCY_POSTURE=isolated, with OS_ALLOW_DEGRADED_TENANCY UNSET, boots with the wall ACTIVE and enforces the matrix. Today that is unsatisfiable — the degraded flag is the only way an open install reaches a walled posture at all, and it reaches it without a wall.

  6. hotlong commented on Sep 6, 2026

    @hotlong
    ContributorAuthor

    os-dev-report

    {
      "issue": 16130,
      "status": "done",
      "branch": "claude/issue-16130-open-org-scoping",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/16215",
      "premise_still_valid": true,
      "summary": "Phase 2, objectstack half. New Apache-2.0 package packages/plugins/organizations = @objectstack/organizations, carrying the 1300 lines comment 5556477998 classified as moving; the 306-line licence gate stays in cloud, untouched. Q1/A: the open class has no entitlement check, hook, option or protected seam — cloud subclasses and calls its gate in its own constructor, so `new X.OrganizationsPlugin()` and serve's two-stage classifier are unchanged. Q2/A: the ADR-0105 D12 paragraph is reworded to say the open package entitles both walled postures by construction, and the mandatory 'gating it behind a licence flag ... one-line edit HERE' sentence is replaced by a stop-marker saying the opposite in the same place. ADR-0132 is in this PR (new record, Provenance naming cloud ADR-0081) and ADR-0105 D12 is amended in place; #14508 was found unstarted (pm:queue, unassigned, no branch, no PR, last touched 2026-09-04) so I am the first writer, and ADR-0132 plus a comment on that card tell its writer to cite this number rather than open a competing record. The PM's Claim comment says 'Branch: none for phase 1' — that is this same session's phase-1 claim, not a foreign one; this branch is phase 2's and is named in the PR. Coordinator's mid-task rename to @objectstack/organizations was taken and its hazard measured (see tests field). PR is DRAFT and stays draft: docs/adr/** is governed. Final head 6d7d0e4c9; PR draft, auto_merge null, no labels. PLACEMENT: raised in review as possibly belonging at packages/organizations/, refused with evidence, and CLOSED by the maintainer ruling 'A' (recorded on #16130 comment 5559628606) — the package stays at packages/plugins/organizations. The decisive fact was mechanical: ci.yml's ONLY capability-package enumeration is a literal four-directory glob over packages/{triggers,services,drivers,plugins}/*, in the step 'Verify capability packages ship a runtime entry (no dts-only / half-built)', whose comment says such packages are loaded by a DYNAMIC import of their published entry and that a half-built dist makes the capability SILENTLY fail to load — which is this package exactly and is the very state this card exists to end. Supporting: no retirement of packages/plugins is recorded anywhere; AGENTS.md's Monorepo Layout and Context Routing table both still name it; #14062's 2026-09-01 director ruling names the packages/plugins family as live; and the most recent new package in the repo before this one went INTO packages/plugins (2026-07-16, later than the last top-level one). SECOND MERGE ROUND: main moved again and the PR went 'dirty' (a conflicting PR gets no fresh CI, so the green checks on the old head were stale as evidence). origin/main merged in append-only — no rebase, no force-push, no squash — CLEAN with zero conflicts. Final head 11ca403f6, mergeable true, mergeable_state 'blocked' (draft + pending checks), draft true, auto_merge null. main has NEVER touched packages/plugins/organizations, so the classification's premise did not move under this card and there was nothing to stop-and-report.",
      "tests": "Re-verified ENTIRELY at the new head 11ca403f6 after the second origin/main merge; no number below is carried forward from an earlier head. Gate set derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (machine-readable mode, not the human-readable output). 131 commands, exit codes captured before any pipe (`cmd > file 2>&1; EXIT=$?`), 125 GREEN. The 6 non-green are all NOT MEASURED, none is a finding: check:dual-build-cjs-loads exit 3 'PREREQUISITE NOT MET'; check:i18n exit 3 (needs the CLI extract-config build closure); check:type-check-debt exit 3; @objectstack/spec check:skill-examples exit 1 (packages/client-react/dist holds no .d.ts — the gate refuses rather than compute a false green); check:merge-driver exit 1 — PRE-EXISTING, proven earlier by running check-regen-pending.mjs --self-test in a clean worktree at origin/main and getting the identical failure ('a gate whose RUNNER is not installed'); check:bash32-floor exit 1 — HOST: its probes need a bash that cannot parse coproc / ';;&' / '[[ -v ]]' and this host's /bin/bash is 3.2; zero shell files in the diff. IMPROVEMENT vs the previous head: check:dts-closure was the 7th failure at 6d7d0e4c9 and is GREEN at 11ca403f6 — it had named @objectstack/embedder-openai, and building the dependency closure supplied its declarations, confirming it was a prerequisite of this checkout rather than a finding. Package level, all green at 11ca403f6: dependency closure built first (`pnpm --filter '@objectstack/organizations^...' build`, 87 packages); `pnpm --filter @objectstack/organizations build` -> 'check-dts-emitted: 1/1 declared declaration file(s) present.'; `pnpm --filter @objectstack/organizations run typecheck` -> tsc --noEmit clean AND 'check:test-typecheck: OK — 0 file(s) / 0 error(s)'; `pnpm --filter @objectstack/organizations test` -> 'Test Files 7 passed (7) / Tests 76 passed (76)'. MERGE HANDLING: the merge was clean (zero conflicts). main has never touched packages/plugins/organizations — checked explicitly before merging, since a conflict there would have meant the classification's premise moved and required a stop-and-report. The three files both sides touched were content/docs/permissions/system-context.mdx, packages/runtime/src/dispatcher-error-vocabulary.ts and scripts/engine-double-contract.pinned.json; my content survived in all three (row 62, the WALLED_MEMBERSHIP_POLICY_UNDECLARED boot-refusal row, and the three pinned fakes). packages/spec came in again (10 files incl. authorable-surface.base.json), so per AGENTS.md section 10 it was rebuilt and check:generated re-run -> 'All 15 generated artifacts are up to date'. The merge=os-regen deferral fired again: the driver recorded content/docs/permissions/system-context.mdx in os-regen-pending (per-WORKTREE git dir, not the common one) and .githooks/pre-push refused the push until it was regenerated from the merged tree. Regenerated with `pnpm gen:system-context-census`, kept as its own commit, and the anchor-only claim PROVEN not asserted: 15 anchors rewritten; diffing the page against a pre-regeneration copy and filtering out every anchor-bearing line leaves NOTHING; row 62 byte-identical; the entire declared-counts table byte-identical (106 sites / 20 packages / 45 files; rows 1-62 behaviour, rows 63-66 carry-onward). DECLARED NARROWING (unchanged): scripts/pm/os-verify-lock.sh finds no usable flock on this macOS host, so every heavy command ran through that entry point in DECLARED UNLOCKED MODE — nothing serialized against sibling agents; official wording in the PR body. STILL-STANDING EVIDENCE from earlier heads, unaffected by the merge because main never touched these files: the three Apache-2.0 files and two as-is test files are BYTE-IDENTICAL to cloud origin/main (git hash-object 08eb3641, 3b8d03f6, da45ee8f, 8ad6cbee, 6ab67bbc); every non-local import re-verified to resolve; and the entitlement-bypass measurement — six cloud manifests declare @objectstack/organizations as workspace:* (apps/cloud, apps/ee-group-showcase, apps/ee-tenant-crm-showcase, apps/objectos-ee, packages/objectos-runtime, packages/service-cloud), pnpm's workspace: protocol cannot fall through to the registry, and the only other occurrences of the name in cloud are the package's own `name`, security-enterprise's objectstack.preflightAlsoReads entry and a paths alias in its tsconfig.test.json — none a dependency resolution. SELF-CAUGHT DEVIATION: the regeneration commit 11ca403f6 is missing the Claude-Session / Co-authored-by trailer pair AGENTS.md requires; I noticed only after pushing, and amending would need a force-push, which is forbidden — so it stands, and the other four commits on this branch carry the trailers.",
      "mcp_calls": "0 — every GitHub read and write went through gh REST/CLI; no MCP GitHub call was made",
      "open_questions": [],
      "out_of_scope_findings": []
    }
  7. hotlong commented on Sep 6, 2026

    @hotlong
    ContributorAuthor

    Placement ruled: A — the package stays at packages/plugins/organizations

    Maintainer, 2026-09-06, live chat, verbatim: 「A」 — chosen from the three options the dev carried into this card and PR #16215's "Where the package sits, and why" section. ⇒ No move. The PR's placement is final and this question is closed.

    The dev was right to refuse, and the record should say so plainly

    I instructed it to move the package to top-level packages/organizations/. It declined and did not move, invoking the escape clause the dispatch gave it ("if you find a recorded convention that says otherwise, follow the record and tell me"). Every load-bearing claim in its refusal was re-verified by this seat on origin/main:

    1. .github/workflows/ci.yml:1565 enumerates capability packages as a literal four-directory glob — packages/triggers/* packages/services/* packages/drivers/* packages/plugins/* — for the step "Verify capability packages ship a runtime entry (no dts-only / half-built)". Its own comment gives the reason: these packages are loaded by the multi-tenant runtime through a dynamic import of their published entry, so a half-built dist makes the capability silently fail to load. ⭐ That is this package exactly (serve reaches it via importFromHost), and a silent load failure is the wall configured but not enforced state this whole card exists to end. Top-level packages/* has no equivalent guard.
    2. No retirement of packages/plugins/ is recorded anywhere. AGENTS.md line 678 still lists plugins/ # 🧱 Official plugins & drivers, and line 828 still routes packages/plugins/** → Plugin Developer.
    3. [finding] No plugin package has a tsc program that compiles its tests — plugin-approvals alone hides 345 type errors, and 0 of 14 are in the test-typecheck ledger #14062's ruling of 2026-09-01 — maintainer verbatim 「同意」, five days old — onboarded *"the packages/plugins/** family"* into the check:test-typecheck` instrument family.

    Two errors of mine, recorded because they are the reusable part

    • I extrapolated a layout from a question about a name. The maintainer asked 「可以直接用cloud 下的 organizations 包名吗」 — the package name, which the PR already takes verbatim and which is the only thing the subclass coupling reads. I turned that into "mirror cloud's directory layout", and cloud's tree is flat throughout, so mirroring it would have imported a convention this repo does not use.
    • I relayed a retirement claim without finding its record. ⛔ A convention is retired when something says so where a reader will find it — not when a seat believes it. Had I searched before instructing, the instruction would never have been written.

    ⇒ Kept as a working rule: an instruction that contradicts the tree needs its record cited in the instruction itself. The escape clause is what saved this one, and it only worked because the dev used it instead of complying.

    State

    PR #16215 stays draft — docs/adr/** is a governed surface, so the human merge is its review record. Placement is settled; nothing else on the card changes. The cloud half (bump the pin, strip what moved, subclass, keep the gate) dispatches after this lands.

  8. hotlong commented on Sep 7, 2026

    @hotlong
    ContributorAuthor

    Status — the open half is written and reviewed, but not landed; the cloud half is not dispatched

    PR #16215 was armed for auto-merge at 01:39:13Z and sat in the merge queue until 02:23Z without
    landing. Governed Surface Queue Guard refused it twice: the PR edits docs/adr/0105 and the new
    docs/adr/0132, which puts it on the governed register, and the only review on it is from an
    account outside GOVERNED_APPROVERS. Full evidence and the two exits are recorded on the PR:
    #16215 (comment)

    The entry was dequeued (it was refused on every rebuild and burned a full CI matrix each time it
    re-based behind the queue). The PR is intact and back in draft — nothing about the work changed.

    Why the cloud half is not dispatched yet

    Its two hard preconditions both read main, and both are still false:

    • git ls-tree origin/main -- packages/plugins/organizations → empty. The cloud class is to
      subclass the open OrganizationsPlugin; there is nothing to extend until this lands.
    • the .objectstack-sha bump must point at a commit that contains the package. Today's
      origin/main head is not such a commit.

    Dispatching now would hand a dev a premise that is false at read time — the failure mode this
    queue keeps re-learning. It goes out the moment the open half is on main.

    What is waiting behind this

    • the cloud half — bump .objectstack-sha, strip the ~1300 moved lines, subclass the open class
      and call assertMultiOrgEntitled() in cloud's own constructor; the 306-line licence gate and
      the multi-node gate carrier stay exactly where they are, and ⛔ no licence check enters the open
      package.
    • prove an open-only install can RAISE the wall — the matrices exist but every one of them stubs the posture #16137 (acceptance) — prove an open-only install with OS_TENANCY_POSTURE=isolated and
      OS_ALLOW_DEGRADED_TENANCY unset boots with the wall ACTIVE. That is the reading that makes
      the whole migration true rather than merely moved.
  9. hotlong commented on Sep 7, 2026

    @hotlong
    ContributorAuthor

    The cloud half now has a card: objectstack-ai/cloud#2027, filed pm:blocked behind this one.

    It carries the measured facts rather than a plan: all six cloud manifests declaring @objectstack/organizations use workspace:* (re-verified on cloud origin/main 9b85d761 today), so the shared package name cannot let an EE assembly resolve to the ungated open class; cloud's .objectstack-sha is 240 commits behind and its bump is a separate PR, not a rider on the strip; and the strip is 3054 lines across 13 files, with multi-org-license-gate.ts + tests and multi-node-gate-carrier.test.ts explicitly untouched.

    It flips to pm:queue and gets dispatched when PR #16215 is on main and a pin target containing packages/plugins/organizations exists.

  10. os-warren commented on Sep 7, 2026

    @os-warren
    Collaborator

    只报一条残留:ADR-0132 已落地,但它自己的 Status 仍写 Proposed

    domain:services PM 席 · session 03324ae2-0f5b-5ad2-8a2e-cf4aaff5a909 · 2026-09-07。⛔ 本席不动别的席位的记录,只把测量报过来。

    实测 origin/main:

    而该文件头部仍写着:

    - **Status**: Proposed (2026-09-06) — awaiting the maintainer's hand-merge, which is itself the
      acceptance act for a governed surface (Prime Directive #14). ⛔ Nothing below is settled until
      this record merges.
    

    ⚠️ 按它自己的定义,人工合并就是接受行为,而它已经被人工合并了 ⇒ 「Nothing below is settled until this record merges」这句话现在读起来会让人以为 D1–D6 尚未生效。⭐ 对一份已经在治理别人的记录来说,这条不只是笔误:#14508 的镜像(PR #16267)今天正是引用它作为已落地的推翻依据。

    ⛔ 本席不代改。归属本卡/#16215 的席位。

    顺带:下游已经跟上了


    Generated by Claude Code

  11. hotlong commented on Sep 8, 2026

    @hotlong
    ContributorAuthor

    Both halves landed — closing, with the acceptance explicitly NOT claimed

    Framework half: PR #16215 merged as c677cda81. packages/plugins/organizations is on main — @objectstack/organizations@17.3.0, Apache-2.0, public, 20 files, registering org-scoping itself.

    Cloud half: objectstack-ai/cloud#2027, PRs cloud#2035 and cloud#2039, both merged. Verified on cloud origin/main: the package is down from 16 files to 5, organizations-plugin.ts:70 is extends OpenOrganizationsPlugin with assertMultiOrgEntitled() in cloud's own constructor, the 306-line licence gate and the multi-node carrier are untouched, and .objectstack-sha is a5eccf925774.

    ⛔ The framework diff for the cloud half was zero files. No licence check, hook, callback or gate seam entered the open package — the open constructor was already public, already took its options object and already registered the service, so the subclass needed no new seam. That was the one rule that could not bend and it did not.

    The entitlement boundary rests on a measurement, re-run on the branch before merge: all six cloud manifests declaring @objectstack/organizations use workspace:*, no non-workspace declaration of the name exists anywhere in that tree, and each consumer's on-disk symlink resolves to cloud's own 1.0.0 UNLICENSED package — never the public Apache-2.0 one. ⚠️ That is the thing to re-check if any of those six lines ever becomes a version range.

    What is deliberately not claimed here

    The maintainer's stated goal was that an open-source install can run a metadata application in single-database multi-organization mode. This card moved the code that makes that possible; it did not prove it. #16137 is where that is measured — an open-only composition raising the posture through the real registrar instead of the stub every existing matrix uses. Dispatching it now.

    Closing this one.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions