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
Conversation
…ore (#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>
…6130) 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>
…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>
… 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>
#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>
📓 Docs Drift CheckThis PR changes 6 package(s): 19 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 2 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 140 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin b76c34ce079ca8042450276374b2eb07b5b3254d && git checkout b76c34ce079ca8042450276374b2eb07b5b3254d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6c546ab9d0b4842bf363caac2a9ff852277680b5 11ca403f6b12361daa1082891ac1cbe97113b654 && git checkout -B drift-repro 6c546ab9d0b4842bf363caac2a9ff852277680b5 && git merge --no-ff 11ca403f6b12361daa1082891ac1cbe97113b654
node scripts/docs-audit/affected-docs.mjs --json 6c546ab9d0b4842bf363caac2a9ff852277680b5
|
… residual The rest of the population #15809 measured, one repair per shape: - TRACKED TARGETS the surrounding prose already names become file-level anchors, and where the prose names the symbol too they become symbol anchors the gate now resolves — `packages/objectql/src/plugin.ts#runGovernanceInventory`, `packages/plugins/plugin-audit/src/audit-writers.ts#resolveWriteLocale`, `scripts/check-system-context-census.mjs#UNENFORCED_TEXT_COUNTS`. A bare `engine.ts` was never ambiguous where the prose said which package. - THIRD-PARTY AND DEPENDENCY SOURCES (`@oclif/core`, `@changesets/pre`) name the package, the file and the pinned line as prose. Nothing in this tree can resolve them, and a pinned upstream line is provenance, not a pointer. - ILLUSTRATIONS take the angle-bracket placeholder spelling. ⛔ Again, no number is repaired or repointed: every digit is still on its page. The residual is 15 citations across six files, and every one of them is a file another lane holds OPEN — three by PR #16215, one by PR #16202, three by the in-flight #15776, one by #12511. NOT ONE is a genuinely ambiguous abbreviation.⚠️ `judgeUntrackedLineAnchors` therefore stays `false`. The fence this work was dispatched under flips it only when the residual is ZERO and a self-test pins the flip; flipping it over 15 citations in files this PR may not touch would make the gate permanently red for the length of somebody else's pull request — the exact shape the card argued against. `--list-unresolvable` now prints that residual, so the next author inherits a worklist rather than a count. Part of #15809. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vbw3RPgdtqesx4azk9SbW8
#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).
…vel anchors Second round of the scripts/** unresolvable-citation migration PR #16301 started. Two of the six held files freed since that PR landed -- check-docs-section-name.mjs and docs-audit/affected-docs.mjs (PR #16300), check-type-check-coverage.mjs (PR #16295) -- so their 7 citations migrate now by the same method: the file named as a file-level anchor, the number kept beside it as data, no digit repaired or repointed. - check-docs-section-name.mjs: concept.mdx:426, doc-pages.mdx:257 and forms.mdx:183 each resolve to exactly one tracked file under content/docs/** (forms.mdx is ambiguous by basename alone -- two files share it -- but only content/docs/ui/forms.mdx has a line 183 carrying the cited "sections": [/* ... */] shape). - check-type-check-coverage.mjs: engine.test.ts:2547/2577 resolves to packages/services/service-automation/src/engine.test.ts, named by the surrounding prose ("service-automation's note"). - docs-audit/affected-docs.mjs: three rest-server.ts citations resolve to packages/rest/src/rest-server.ts, the only tracked file of that name. The corpus residual (scripts/check-scripts-symbol-anchors.mjs --list-unresolvable) drops from 15 to 8, all 8 still held by open PRs (#16215, #16202) -- none an ambiguity. judgeUntrackedLineAnchors stays false per the #15809 fence (flips only at residual zero). Header prose updated to record both dated readings. Part of #15809. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vbw3RPgdtqesx4azk9SbW8
Part of #16130 — phase 2, the objectstack half only. The cloud half (bump the framework pin, strip what moved, subclass, keep the gate) is a separate dispatch and is not touched here.
⛔ DRAFT, and it stays draft. The diff touches
docs/adr/**, a governed surface: the maintainer hand-merges. Do not flip ready, do not arm auto-merge, do not queue.What this delivers
The
org-scopingregistrar, in open core. Everything else the maintainer's goal needs was already open — the Layer 0 wall, the three postures, the posture knob, the organization and invitation objects, better-auth's organization plugin inplugin-auth, and therequiresService: 'org-scoping'Setup gates. The registrar was the one missing piece, and without it an open install that setOS_TENANCY_POSTURE=isolatedcould not enforce it: its only route forward wasOS_ALLOW_DEGRADED_TENANCY=1, the wall configured but not enforced.New package
packages/plugins/organizations—@objectstack/organizations, Apache-2.0, carrying the 1300 lines comment 5556477998 classified as moving. The 306-line licence gate stays in cloud.Package name — measured, not assumed
The name is
@objectstack/organizations, the spelling the open CLI'sORGANIZATIONS_RUNTIME_PKGalready carries, so the loader needs zero changes. Two readings behind that:npm view @objectstack/organizationsreturns E404 — never published, so the name is free. (For contrast, the historical@objectstack/plugin-org-scopingis on npm at12.6.0, owned by the org, not deprecated and with no per-version deprecations — recorded here because the earlier proposal would have resumed it at 17.x with a gap.)plugin-org-scoping; all but four are CHANGELOGs. The four are ADR-0056 / ADR-0057 consumer lists,plugin-security's README (corrected below), and three fixtures insidescripts/check-published-readme-exports.mjswhose workspace maps are hand-supplied, so none of them resolves against the real tree. Nothing live points at the old name.Where the package sits, and why —
packages/plugins/organizationsRaised in review as possibly belonging at top level (
packages/organizations/), mirroring cloud's layout. Kept underpackages/plugins/on the record and on measurement. Stating the evidence so the maintainer can overrule it at hand-merge with all of it in view:.github/workflows/ci.yml— "Verify capability packages ship a runtime entry (no dts-only / half-built)" — enumerates exactlypackages/triggers/* packages/services/* packages/drivers/* packages/plugins/*, and it is the only such enumeration in that file; top-levelpackages/*has no equivalent. Its own comment says why it exists: "Capability packages (services / triggers / plugins) are loaded by the multi-tenant runtime via a DYNAMIC import of their published entry", and a half-builtdistmeans the capability silently fails to load. That is this package exactly —servereaches it throughimportFromHost, a dynamic import of the published entry — and a silent failure to load is precisely the "wall configured but not enforced" outcome this whole card exists to end. This package qualifies for that step today (buildscript,main: dist/index.js).plugins/ # Official plugins & drivers, and its Context Routing table routespackages/plugins/**→ Plugin Developer · Implements spec contracts. A repo-wide search finds no recorded retirement of that directory.packages/plugins/**family" into thecheck:test-typecheckinstrument family. This package'stsconfig.test.jsonfollows that family's shape and says so.packages/plugins/(plugin-pinyin-search, 2026-07-16) — later than the last new top-level package (sdui-parser, 2026-06-29).Plugin. It implements thePlugininterface and is mounted withkernel.use(). Three of the fifteen entries underpackages/plugins/already carry noplugin-prefix (knowledge-memory,knowledge-ragflow,embedder-openai), soorganizations→@objectstack/organizationsmatches the directory-basename convention there.packages/organizations,packages/service-cloud,packages/security-enterprise), so mirroring its layout would mean adopting a convention this repo does not use.packages/plugins/is a real maintainer ruling rather than an inference, this is a one-command move — but it should land with the ci.yml enumeration widened in the same change, or the runtime-entry guard is lost for the one package whose whole contract is being dynamically imported.⭐ The shared name, and how the entitlement stays mounted on EE
The commercial repo keeps a private package of the same name. That is the mechanism, not a collision — but it is also the sharpest hazard in this card, so it was measured rather than reasoned:
objectstack serveimportFromHost('@objectstack/organizations')—createRequire(join(hostRoot,'package.json')), gated on the served app's own declaration (#4719)artifact-kernel-factory.tsimport('@objectstack/organizations')from insidepackages/objectos-runtime, which declares itservice-cloud's control-plane presetlazyPlugin('com.objectstack.organizations', ...)reachingcontrol-plane-organizations.js, in a package that declares itAll six cloud manifests that declare the name declare it
workspace:*—apps/cloud,apps/ee-group-showcase,apps/ee-tenant-crm-showcase,apps/objectos-ee,packages/objectos-runtime,packages/service-cloud— and cloud's lockfile resolves every one tolink:its local package. The name appears three more times in cloud outside a dependency field, and none of the three is a dependency resolution: the package's ownname,security-enterprise'sobjectstack.preflightAlsoReadsarray entry, and apathsalias in that package'stsconfig.test.json. (Independently re-verified in review, which reached the same six and the same exceptions.) pnpm'sworkspace:protocol resolves only to the local workspace package — it cannot fall through to the registry, and a missing one fails the install rather than substituting silently. So the class an EE deployment mounts is decided by the manifest that declares the name, on all three paths, with no change to the open package's shape and no change to the loader.What the cloud half must preserve (stated now because it constrains that PR): keep the package name, keep it
private: true, and keep every host declaration atworkspace:*— never a semver range.What could break it, and is therefore forbidden here: a framework package taking
@objectstack/organizationsas its own dependency. Cloud consumes the framework bylink:, so that would put the ungated copy inside the tree an EE app links, reachable by a bare import that never consults the app's manifest — the entitlement bypassed by resolution rather than by any defect in the gate. Apps declare this package; packages do not.src/no-framework-dependents.pin.test.tsholds it mechanically, with an anti-vacuity control on the population and a control proving the detector can find a planted declaration.The three hard boundaries
multi-org-license-gate.tsstays in cloud. Not moved, not reimplemented, and no hook left for it. Its 306 lines and 12 exported symbols are untouched.MULTI_NODE_GATE_CARRIER_PACKAGESis unedited and the open package acquires neither the obligation nor thesecurity-enterpriseimport that discharges it.unavailable) and now succeeds while registering nothing (loaded-without-gate). Both leave no gate registered, so the fail-closed default refuses a multi-node verdict exactly as before.assertMultiOrgEntitled()and its import are the 18 deleted lines. Per Q1 → A, cloud subclasses and calls its gate in its own constructor; the open class takes no entitlement callback, hook, option or protected seam, andnew X.OrganizationsPlugin()keeps working for cloud's two call sites. A ⛔ comment sits where the call was, naming what may not be added there.Q2 — the two comment blocks
['group','isolated']moves as-is.The service name
org-scopingis unchanged, and so is the plugin idcom.objectstack.organizations. Both are anchored by open consumers and by cloud fixtures; the "kept on purpose" comment moves with the registration.The ADR amendment is in this PR
Provenancesection naming cloud ADR-0081 and its date, the D3 resolution mechanism, and the boundaries the code comments cite by number.supportedPosturesin the commercial runtime does not carry to the open package. Its status line records the amendment.#14508 as found: still
pm:queue, unassigned, no branch, no PR, last touched 2026-09-04 by triage. I am the first writer. Its scope is cloud ADR-0081 D1/D3/D4 (the open basics half) and its Shape section says D2 "stays in cloud" — ADR-0132 is what makes that one line stale. ADR-0132 says so explicitly and asks #14508's writer to cite this number rather than open a competing record; a comment on #14508 says the same.Dispositions changed from the phase-1 classification
Comment 5556477998 is the source of truth for what moves. Six changes, every one forced by a gate the open tree runs and cloud's does not — none of them changes what the code does:
claim-orphan-org-rows.test.ts,claim-org-seed-ownership.test.tscheck:engine-double-contract— the fakes'update()now opens withassertEngineUpdateDispatchorg-creation-no-cross-org-copy.test.tswalled-default-org-self-registrant.pin.test.tscheck:where-matcher— it read$and/$oras a field name, so a filter it cannot express answered "no rows", which every negative assertion in that file would have read as evidenceorganizations-plugin.tscheck:slot-lookup— theobjectql/metadatalookups are typed structurally instead of erased toany; that ratchet's baseline never growsorganizations-plugin.ts,membership-policy-gate.tscheck:doc-authoring— tracker ids leave a rendered boot refusal and a log line, and move to adjacent commentsADR-0081citationcloud ADR-0081ADR-0081resolves against this repo's ADR-0081 (the React page tier) — the exact collision ADR-0105's own citation note documentsThe three Apache-2.0 files move byte-identical — verified by
git hash-objectagainstcloud origin/main:packages/organizations/src/*(08eb3641…,3b8d03f6…,da45ee8f…and the two as-is test files), not by an insertion count.Consequence edits — claims this PR falsifies
Three places in the open tree assert something that stops being true on merge. Each is corrected here rather than deferred, and none changes behaviour:
packages/plugins/plugin-security/README.md— said "No such package exists — not on npm, and in no directory of this repo." It is a published README (the page npm renders). Now states the package is Apache-2.0 and in this repository, and how an EE deployment resolves the same name to a gated subclass.packages/spec/src/kernel/platform-capabilities.ts— the roster row's note said "closed-source … not on the public npm registry". Corrected. ⛔edition: 'enterprise'and the row key are untouched: the row records the entitled variant, and the carrier list's drift test reads that field.packages/services/service-cluster/src/multi-node-gate-mount.ts— the carrier header now records the one-name-two-packages fact and theunavailable→loaded-without-gatediagnostic change, with a ⛔ against "repairing" it.Plus the gate-driven bookkeeping a new package owes: the
isSystemcensus row for theorganization_idauto-stamp's elevation short-circuit (row 62, with the page's counts moved), the boot-refusal row forWALLED_MEMBERSHIP_POLICY_UNDECLARED, thecheck:cross-package-test-inputsradius plus itsturbo.jsontask for the D3 pin,llms.txt68 → 69, and the changesetfixedgroup.⛔ Not edited, deliberately:
packages/verify/src/harness.ts's "requires the enterprise @objectstack/organizations package" message andpackages/cli'sserve/doctorresolution. Teaching those about an open registrar is #16137's, and doing it here would be the wiring this PR explicitly does not claim.⛔ This PR does not claim the acceptance criterion
An open-only install with
OS_TENANCY_POSTURE=isolatedandOS_ALLOW_DEGRADED_TENANCYunset, booting with the wall ACTIVE and enforcing the matrix, is not measured here and is not claimed.objectstack servestill resolves the runtime from the served app's own declaration and is not wired to mount this package off the posture. That acceptance is discharged by #16137, which is blocked on this — per the PM's Q3 answer it is "run the existing open matrices (single-kernel-isolated-api-key-matrix.test.ts,single-kernel-isolated-session-org-claim-matrix.test.ts) against the real registrar instead of their hand-writtenposture: 'isolated'stub", not "build a walled showcase app".Verification
Declared narrowing — verification ran UNLOCKED.
scripts/pm/os-verify-lock.shcould not take the shared verify lock on this host: no usableflock. The shared verify lock is declared Linux-only (flockis util-linux, and a stock macOS does not ship it), so every command below was run through that entry point without the lock — a declared narrowing, not a silent one. No serialization guarantee held for these runs, nor for any sibling agent in this container while they ran.Gate set derived mechanically —
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, the machine-readable mode, never the human-readable output. 131 commands, run at11ca403f6— the final commit, after the SECONDorigin/mainmerge — 125 green. (Prior heads for comparison:6d7d0e4c9124 green,494cb77f7124 green, same split each time.) The six that are not green are all NOT MEASURED, none is a finding, and each is named with its reason:check:dual-build-cjs-loadsdist/in this tree.check:i18ncheck:type-check-debt@objectstack/spec check:skill-examplespackages/client-react/distholds no.d.ts; the gate refuses rather than compute a false green.check:merge-drivercheck-regen-pending.mjs --self-testfails identically in a clean worktree atorigin/main9f3989771(case: "a gate whose RUNNER is not installed"). Baseline captured, worktree removed.check:bash32-floorcoproc/;;&/[[ -v ]]; this host's/bin/bashis 3.2 and cannot even parse the probe text. Zero shell files in this diff.CI builds fresh on Linux, so all six are measured there. ⭐
check:dts-closurewas the seventh on the previous head and is green at this one: it had named@objectstack/embedder-openai, and building the dependency closure supplied its declarations — which confirms it was a prerequisite of this checkout rather than a finding about the diff.origin/mainwas merged into this branch twice (the PR readmergeable_state: dirtyon creation, and a conflicting PR gets no CI at all). The merge broughtpackages/spec, so per AGENTS.md §10 it was rebuilt andcheck:generatedre-run — All 15 generated artifacts are up to date. The merge driver also deferredcontent/docs/permissions/system-context.mdx, which is amerge=os-regenartifact git ran no text merge on;.githooks/pre-pushrefused the push until it was regenerated from the merged tree, and that regeneration is its own commit. It rewrote anchor lines only — 18file.ts:linecitations the incoming commits moved — with row 62, the section extents and every declared count unchanged. The whole gate set, the build closure,typecheckand the 76 tests were then re-run on the merged head.Second round, same two traps, same handling.
mainmoved again and the PR wentdirty; a conflicting PR gets no fresh CI, so a green badge on it is a false read. The merge was append-only — no rebase, no force-push, no squash — and clean, with zero conflicts. ⭐mainhas never touchedpackages/plugins/organizations, so the classification's premise did not move under this card; the three files both sides touched weresystem-context.mdx,dispatcher-error-vocabulary.tsandengine-double-contract.pinned.json, and my content survived in all three (row 62, the boot-refusal row, the three pinned fakes).packages/speccame in again, so it was rebuilt andcheck:generatedre-run — All 15 generated artifacts are up to date. Themerge=os-regendeferral fired again and.githooks/pre-pushrefused until the census was regenerated from the merged tree; that regeneration is its own commit and rewrote anchor lines only — 15 this time, 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 entire declared-counts table is byte-identical (106 sites / 20 packages / 45 files; rows 1–62 behaviour, rows 63–66 carry-onward). Every number in this section is from11ca403f6; none is carried forward.Package-level, all green at
11ca403f6:pnpm --filter '@objectstack/organizations^...' build— dependency closure first, in the new worktree.pnpm --filter @objectstack/organizations build—check-dts-emitted: 1/1 declared declaration file(s) present.pnpm --filter @objectstack/organizations run typecheck—tsc --noEmitclean andcheck:test-typecheck: OK — 0 file(s) / 0 error(s).**/*.test.ts, so "typecheck clean" alone would say nothing about the test layer.tsc --listFiles -p tsconfig.test.jsonputs all 14 files (7 source + 7 test) in the program — measured, not assumed. Zero residue, so the package ships notest-typecheck-debt.jsonand nogen:script, matching the ten zero-residue plugin packages rather than the four that carry ledgers.pnpm --filter @objectstack/organizations test— 7 files, 76 tests, all passing.serve-cluster-host-resolution,serve-organizations-message-spelling,doctor-organizations-message-spelling,serve-organizations-host-resolution,test/serve-capability-vocabulary— 67 tests, all passing, on a built closure.packages/spec'splatform-capabilities.test.ts: 27 passing.Imports re-verified rather than trusted. Every non-local specifier the moving code names was resolved against this tree:
Plugin/PluginContextandresetPlatformAdminEmailMemofrom@objectstack/core;isDefaultOrganizationBootstrapTrigger,ensureDefaultOrganization,EnsureDefaultOrganizationResult,isMembershipPolicy,MEMBERSHIP_POLICIESfrom@objectstack/plugin-auth(all reached through itsexport *barrel);resolveTenancyPosturefrom@objectstack/types;ServiceObjectfrom@objectstack/spec/data. Nothing failed to resolve, and nothing needed a shim.And the verdicts are about the source, not the last build.
tsconfig.jsoncarriespathsfor all five workspace dependencies andvitest.config.tscarries anchored-regex aliases for the three the tests reach as values — socheck:type-source-resolutionandcheck:test-source-aliasboth pass with this package resolving source, rather than the package being registered in either gate's shrink-only unaliased ledger.