Skip to content

Commit 79a046f

Browse files
fix(lint): give the tenant-audit census refusals an exit to CI (#18534)
Fixes #18211 Clause-②: no ## What Two halves, and the second is what makes the first landable. 1. **`scripts/check-tenant-audit-census.mjs` gains check C, `censusRefusals()`** — it reads `census.unledgered` and `census.staleLedgerRows` off the census it already runs and routes both to the gate's exit code. 2. **The two sites that check C then found are fixed at the receiver** — `claimOrphanOrgRows` and `claimOrgSeedOwnership` took `ql: any`, so they are given the narrow `OrgScopingEngine` type they actually call through. ## Why check C `runCensus()` reports two failures about the **tree** rather than about the artefacts: `unledgered` (a write call site whose receiver is erased and that none of the three placement rules reaches) and `staleLedgerRows` (an `UNTYPED_RECEIVERS` row matching no call). The generator's own `main()` prints both and exits 1. But `lint.yml` (lines 1995/1996 on this tree) invokes the **gate**, never the generator — and the gate read neither field. Measured on this branch's base `1e496f979`: | file | `unledgered` reads | `staleLedgerRows` reads | control: `writeCallSites` reads | |:---|---:|---:|---:| | `scripts/check-tenant-audit-census.mjs` | **0** | **0** | 17 | | `scripts/tenant-audit-census.mjs` | 3 | 3 | 5 | The control is counted in the same file as each zero, so those zeros are readings, not a grep that failed to fire. So the census could find an unplaceable receiver, say so to nobody, and `Lint & Repo Gates` stayed green — a defect arriving as **compliance**. This direction is published: `content/docs/permissions/tenant-audit-census.mdx` tells readers that a receiver none of the three place is an error, never a default. The fix is the **read**, not a second run of the generator in the workflow: that would walk the same corpus and build the same AST twice for one verdict the gate already holds in hand. `.github/workflows/**` is untouched. ## Why the receivers are typed, and not ledgered Check C found exactly two unplaced sites, both `ql: any` seed/back-fill helpers writing `schema.name` under `context: SYSTEM_CTX`: - `packages/plugins/organizations/src/claim-org-seed-ownership.ts` (`:52` the parameter, `:93` the write) - `packages/plugins/organizations/src/claim-orphan-org-rows.ts` (`:61` and `:106`) Both match the already-ledgered `plugin-security/src/claim-seed-ownership.ts` row word for word, so they are engine writes and `engine: false` was never on the table. What settles the remaining choice is the ledger's own first line, `scripts/tenant-audit-census.mjs` at `origin/main` `1e496f979`, line 803: **"SHRINK-ONLY, and keyed by (file, receiver) — never by line"**. Adding two rows to a shrink-only ledger runs against its own discipline. A typed receiver needs no row at all, so `UNTYPED_RECEIVERS` is untouched and `placedByLedger` stays at **11**. `OrgScopingEngine` follows `OrphanCleanupEngine` in `plugin-sharing`: a narrow, locally declared interface naming only the doors these functions call — `find`, `update`, and an optional `registry`. Optional on purpose, because "registry unavailable" is a real, tested, logged no-op path that the type has to be able to describe. It is **package-private**, and that is load-bearing rather than incidental. The census reads the type declared at the **receiver**, in this source tree; it never reads the package's public entry. Exporting the interface from `index.ts` therefore bought the placement nothing and only widened a published surface — so `src/index.ts` exports exactly the nine names it exported before, byte for byte: Taken by diffing the `^export` lines of that file as `git show` prints them at `origin/main` against the same lines at `HEAD`: **exit 0, no output, 9 lines on each side.** Fire control for that zero: the identical comparison run against the commit that *did* carry the export reports one added line — the `export type { OrgScopingEngine }` re-export — and exits 1. So the comparison can see an added export, and is reporting none. The emitted declarations still carry `interface OrgScopingEngine` inline, so a consumer's call resolves without ever naming it; it is simply absent from the shipped export list. ### The caller had to state it too Naming the parameter turned an invisible coupling into a type error: `OrgScopingQuerySlot` in `organizations-plugin.ts` declared the three members the plugin calls itself — but the plugin also **forwards** that value to `claimOrphanOrgRows`, which writes through it. That is the finding, not an obstacle, and the slot now extends `OrgScopingEngine` to say so. ## Measurements All commands run in a dedicated worktree on `origin/main@1e496f9` after `pnpm install`. **Acceptance 1 — the generator, on the day** (`node scripts/tenant-audit-census.mjs`): - exit code **1** - `unledgered` — **2** entries, the two sites above - `staleLedgerRows` — **0** entries (empty). Fire control for that zero: the same `--json` dump reports `unledgered.length = 2` and `unresolved.length = 2`, so the reader is live. - Population: 223 write call sites, 569 sources scanned. **Acceptance 3 — both directions, measured twice.** First on the gate-only commit, to show check C is real: | direction | how | gate exit | |:---|:---|---:| | red on the day's sites | check C, before the receivers were typed | **1** (2 x `[untyped-receiver]`) | | same tree, no check C | `HEAD~1` | **0** | | green once the sites are placed | one-shot ablation removing them from the corpus | **0** | Then again on the finished tree, which is the direction that matters now: | leg | gate exit | `[untyped-receiver]` lines | |:---|---:|---:| | finished tree | **0** | 0 | | erase ONE receiver back to `ql: any` | **1** | **1**, naming the file and line | | same mutated tree, `censusRefusals()` neutered | 1 (drift/prose only) | **0** | The third row is the control: with check C disabled, the untyped-receiver finding disappears while the unrelated findings remain — so that red is unambiguously check C's and nothing else's. All mutations were one-shot, each proven on disk by counting both the injected and the removed string before any result was read, each script carrying `trap restore EXIT INT TERM` with absolute paths, and each restored to a blob hash equal to `git rev-parse HEAD:PATH` with `git diff HEAD --stat` empty afterwards. **The self-test was made to fail before its green was believed:** neutering `censusRefusals()` reds it with 4 of 24 cases failing by name; deleting the whole `census refusals` battery block reds it with `self-test battery "census refusals" DID NOT RUN — 0 cases registered, 5 pinned`. The new battery carries its own control (a census with neither an unplaceable site nor a stale row is **not** a finding), so its four positive cases cannot be passed by a function that simply reports everything handed to it. **What the population did.** 223 to **225**, and both new sites read as elevated. Worth recording, because on the gate-only commit the population was **also** 223 with the two sites unplaced: they were never counted at all — they were the hole in the certified population, and nothing on the way to a CI verdict said so. The generated region and the audit ledger are regenerated with `--write`; the page's eight hand-written prose figures are restated by hand, which `--write` does not do. **Package verification:** `pnpm --filter @objectstack/organizations typecheck` and `test` both exit 0 — 8 test files, 108 tests. No test file changed: both fakes are declared `const ql: any`, which the narrowed parameter accepts. No in-repo package depends on `@objectstack/organizations`, so the consumer sweep is empty by construction rather than by omission. **Clause-②:** the whole diff is a **narrowing**. Two exported function parameters go from `any` to an interface; `OrgScopingQuerySlot` is declared without `export` and stays package-private; and the package entry gains no name, measured above. Nothing relaxes an accepted set and nothing widens a published surface. ## Changeset **A changeset is required and `skip-changeset` has been removed** — the judgement flipped when the diff grew past `scripts/`, and it was re-verified rather than assumed. `@objectstack/organizations` is not private and ships `files: ["dist", "README.md", "CHANGELOG.md"]`. After `pnpm --filter @objectstack/organizations build`, the shipped `dist/index.d.ts` declares `claimOrphanOrgRows(ql: OrgScopingEngine, ...)` where it previously declared `ql: any`, and exports the new `OrgScopingEngine` type. Fire control for that reading: the same grep over the same file scores **0** for a symbol that should not be there. Bumped `minor`, not `patch`: runtime behaviour is unchanged, but a consumer passing a value that does not structurally offer `find` and `update` no longer compiles. Such a consumer already got `[]` and a warning from the existing guards, so nothing that worked stops working — the failure moves from run time to build time. ## Gates run Derived from the diff with `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` and reconciled with `--ran`, every line recording its exit code: **105 derived, 105 run, 0 NOT-MEASURED, 0 UNRUN**, and the tool confirms that zero is derived from the recorded codes rather than claimed. `pnpm check:pm-dispatch-gates` (the bare ~1746-case battery) exits **0** in 814.8s, run detached and waited on with `tail --pid`. Two families needed a built tree and say so themselves rather than skipping (`check:skill-examples`, `check:dual-build-cjs-loads` at `exit 3`, "PREREQUISITE NOT MET"); both were re-run after a full `pnpm build` and both exit **0**. One family reds locally and is **not** this diff: `pnpm check:cross-package-test-inputs` reports that `@objectstack/cli` descends from `packages/spec/dist/` through a radius no declared glob reaches, rooted in `packages/cli/test/init-created-files-summary.e2e.test.ts` — a file this PR does not touch. It reds only because a local `packages/spec/dist/` exists. Proven by moving that directory aside and re-running: **exit 0**, `29 package(s) read outside themselves, all declared`. The `lint` job that runs this gate does not build, so CI sees the unbuilt state. Reported upward as a finding in its own right. ## 验收备注 卡面四条,原样照抄: 1. 先 `pnpm install`,再重跑 `node scripts/tenant-audit-census.mjs`,读**今天的**退出码与 `unledgered` / `staleLedgerRows` 的**实际内容**。⛔ 零要有发火对照。 2. 给这两个字段一条**到 CI 的出口**。⛔ 不许用「把 census 也加进 `lint.yml` 跑一遍」糊过去(那会让同一份 AST 走两遍),修法落在门禁文件内。 3. ⭐ **两个方向的对照都要**:门禁对今天这些站点**必须红**;去掉其中一个(或补上 ledger 条目)之后**必须绿**。⛔ 只给一个方向不算量过。 4. ⛔ **不许为了让门禁绿而把那些站点写进 ledger 当既成事实** —— 先裁它们该不该被落位,再决定记不记。⚠️ 若你判断需要这次裁定,**回报 PM 席**,⛔ 不要自己裁。 Verdicts: 1 — measured above, with a fire control on each zero. 2 — `censusRefusals()` in the gate file; no workflow touched; the corpus is walked once. 3 — both directions measured, twice. 4 — the ruling was escalated and returned as "type the receivers, do not grow the ledger"; no `UNTYPED_RECEIVERS` row was written. Two things the card recorded as unmeasured, now measured: the generator does exit **1** on the day's tree, and `staleLedgerRows` is **empty**, so the whole of that red was the unplaceable-receiver half. ### The two regenerated artefacts `content/docs/permissions/tenant-audit-census.mdx` and `docs/audits/2026-08-tenant-audit-write-call-sites.counts.md` are in the diff as the **mandatory companions** of the population moving 223 to 225, not as independent edits. - Both are written by `node scripts/tenant-audit-census.mjs --write`, the gate's one documented repair arm. Re-running it on the finished tree rewrites nothing. - **Eight** figures on the page were changed **by hand**, because `--write` does not touch the page's hand-written prose: `223` to `225` in five places, `74` to `76`, and `104` to `106` in two. Each is a figure `PROSE_COUNTS` in the gate holds to the census, and the gate names every one of them — the eight edits are exactly the eight it named, no more. The hand-written `(47%)` beside the elevated share was re-checked and still rounds to 47. - Nothing else on either artefact was hand-edited, and the last round of work moved neither: `git diff --stat` over both paths is empty after removing the package export, which is the evidence that a visibility change moves no census reading. ### Noted, not filed - `main()` in `scripts/tenant-audit-census.mjs` returns 0 from its `--write` branch before it reaches the `unledgered` / `staleLedgerRows` reporting, so `--write` is silent about both. It is a repair arm rather than a verdict and CI never calls it — an observation, not a defect class. Next toucher: anyone regenerating these artefacts, since `--write` is the command they run. ## Attribution Authored by Claude Code, session `session_017ef78bLdybu3AffehKkhfk` (https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk). The tail footer below this line is appended by the platform on every body edit, which is why this PR carries its session id in prose rather than only in that footer. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 582d3e5 commit 79a046f

8 files changed

Lines changed: 217 additions & 39 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/organizations": minor
3+
---
4+
5+
`claimOrphanOrgRows` and `claimOrgSeedOwnership` name the ObjectQL doors they write through — a package-private `OrgScopingEngine` interface replaces `ql: any` on both, and `OrgScopingQuerySlot` states the doors the plugin forwards rather than only the three it calls itself (#18211).
6+
7+
The package's public entry is unchanged: `src/index.ts` exports exactly the nine names it exported before, byte for byte. What moved on the published surface is the two exported functions' signatures, and nothing else.
8+
9+
Runtime behaviour is unchanged: the same guards run, the same rows are updated, and an engine without a `registry` still returns `[]` with a warning instead of throwing — `registry` is optional on the new type precisely so that tested path stays describable.
10+
11+
- **Why a type and not a comment.** The tenant-audit census decides whether a write call site is an engine write by reading the **receiver's declared type**. An `any` receiver has no type to read, so both of these sites were reported as sites nothing could place — an error in that census, never a default, because a write it cannot see is a write the tenant-audit population does not certify. Naming the doors places both by type. The certified population moves 223 to 225 and both read as elevated (they write under `context: SYSTEM_CTX`).
12+
- **Narrow on purpose**, following `OrphanCleanupEngine` in `@objectstack/plugin-sharing`: `OrgScopingEngine` declares `find`, `update` and an optional `registry`, and nothing else. Widen it by adding a door that is actually used, never by re-exporting the engine's full contract — and keep it package-private: the census reads the type declared at the receiver, never the package entry, so exporting it would widen a published surface and buy the fix nothing.
13+
- **The slot change is a finding, not a refactor.** `OrgScopingQuerySlot` declared `registerMiddleware`, `find` and `getSchema` — but the plugin also hands that value to `claimOrphanOrgRows`, which writes through it. While the back-fill's parameter was `any` that coupling was invisible to the type system; naming the parameter turned it into a type error, and the slot now states it.
14+
- **Type-level tightening for consumers.** A caller passing a value that does not structurally offer `find` and `update` no longer compiles. Such a caller already got `[]` and a warning at run time from the existing guards, so nothing that worked stops working — but the failure moves from run time to build time, which is why this is not a patch. The parameter type is inlined into the emitted declarations, so a consumer never needs to name it.
15+
- ⛔ **No `UNTYPED_RECEIVERS` ledger row was added.** That ledger is documented shrink-only and keyed by (file, receiver); growing it by two rows to silence two sites runs against its own discipline, and a typed receiver needs no row at all.

‎content/docs/permissions/tenant-audit-census.mdx‎

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ are reported as `undecidable` rather than assumed either way.
106106

107107
The same holds twice over for the context. An options argument spelled as a
108108
literal can be read; one spelled `options`, `{ ...opts }`, or handed through a
109-
forwarding shim cannot, and **67 of the 223 sites are spelled that way**. A
109+
forwarding shim cannot, and **67 of the 225 sites are spelled that way**. A
110110
context resolved from an inline literal or a local `const` can be tested for
111111
`isSystem`; one arriving from a helper call cannot.
112112

@@ -155,10 +155,10 @@ reproduce them. Where it disagrees, it disagrees on the page:
155155

156156
| carried figure | where it survives | this census |
157157
| :--- | :--- | ---: |
158-
| 175 write call sites | quoted in the merged changeset | **223** |
158+
| 175 write call sites | quoted in the merged changeset | **225** |
159159
| 24 carrying no tenant context | quoted in the merged changeset | **9** provable and tenancy-enabled; **32** more whose options argument is unreadable |
160-
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **149 of 223** decidable, **74** undecidable |
161-
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 104 decidably elevated, 0 decidably not, 102 undecidable |
160+
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **149 of 225** decidable, **76** undecidable |
161+
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 106 decidably elevated, 0 decidably not, 102 undecidable |
162162
| 141 and 132, two independent re-derivations | the card that filed this work | — |
163163

164164
**The differences are not reconciled, and deliberately so.** The old census's
@@ -175,11 +175,11 @@ would report a smaller number and would not say so.
175175

176176
The fourth row is the one worth flagging to anyone citing it. **The 135 / 77%
177177
figure has no surviving corroboration anywhere in the tree.** This census reads
178-
104 of 223 (47%) as decidably elevated, with 102 more whose elevation is a
178+
106 of 225 (47%) as decidably elevated, with 102 more whose elevation is a
179179
run-time fact — so the claim is neither confirmed nor refuted, and the honest
180180
answer is that a static reading cannot settle it.
181181

182-
⇒ **Cite `9 / 223`, and say what it is**: the sites whose options argument was
182+
⇒ **Cite `9 / 225`, and say what it is**: the sites whose options argument was
183183
READ and holds no tenant context, against a decidably tenancy-enabled object.
184184
That is the control's provable yield surface. ⛔ Do not cite it as "the sites
185185
without tenant context" — **32 further sites** have an options argument this
@@ -191,31 +191,31 @@ cannot read, and they are neither in nor out.
191191

192192
| what | count |
193193
| :--- | ---: |
194-
| write call sites on the application surface | **223** |
194+
| write call sites on the application surface | **225** |
195195
| …whose object name is statically decidable | 149 |
196-
| …whose object name is chosen at run time | 74 |
196+
| …whose object name is chosen at run time | 76 |
197197
| …against an object with tenancy ENABLED | 149 |
198198
| …against an object that declares tenancy off | 0 |
199-
| threading a tenant context | 139 |
199+
| threading a tenant context | 141 |
200200
| PROVABLY carrying none (options read, no context key) | **17** |
201201
| …of those, against a decidably tenancy-enabled object | **9** |
202202
| options argument UNREADABLE — may or may not carry one | 67 |
203203
| …of those, against a decidably tenancy-enabled object | 32 |
204-
| threading a decidably ELEVATED (`isSystem`) context | 104 |
204+
| threading a decidably ELEVATED (`isSystem`) context | 106 |
205205
| threading a context that is decidably NOT elevated | 0 |
206206
| threading a context whose elevation is a run-time fact | 102 |
207207

208208
| how the instrument reached the site | count |
209209
| :--- | ---: |
210-
| receiver carried a readable engine type | 179 |
210+
| receiver carried a readable engine type | 181 |
211211
| receiver erased, placed by the object NAME | 18 |
212212
| receiver erased, placed by an `object: string` PARAMETER | 15 |
213213
| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 11 |
214214

215215
| object name spelled inline | 109 |
216216
| object name spelled through a `const` | 40 |
217217
| object name is an `object: string` parameter | 19 |
218-
| object name is some other run-time expression | 55 |
218+
| object name is some other run-time expression | 57 |
219219

220220
The corpus walked is every tracked non-test source under `packages/services/`
221221
and `packages/plugins/`; calls to a same-named method on something that is not
@@ -232,13 +232,13 @@ holds still. They are required to be HERE and to say WHEN they were true;
232232
their values are not compared. The reasoning, and the measurement behind it,
233233
are in `scripts/check-tenant-audit-census.mjs`.
234234

235-
Measured on 2026-09-14 at `d4554d4f5`.
235+
Measured on 2026-09-16 at `11daf7f69`.
236236

237237
| corpus scale (not enforced) | count |
238238
| :--- | ---: |
239-
| tracked non-test sources scanned | 568 |
240-
| engine-shaped types recognised | 61 |
239+
| tracked non-test sources scanned | 570 |
240+
| engine-shaped types recognised | 63 |
241241
| declared objects in the registry | 117 |
242-
| same-named calls subtracted as non-engine | 140 |
242+
| same-named calls subtracted as non-engine | 146 |
243243

244244
{/* END GENERATED: tenant-audit-census */}

‎docs/audits/2026-08-tenant-audit-write-call-sites.counts.md‎

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,17 +29,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution.
2929

3030
| Measure | Value |
3131
|---|---:|
32-
| Write call sites | 223 |
32+
| Write call sites | 225 |
3333
| Object name statically decidable | 149 |
34-
| Object name chosen at run time | 74 |
34+
| Object name chosen at run time | 76 |
3535
| Against a tenancy-enabled object | 149 |
3636
| Against an object declaring tenancy off | 0 |
37-
| Threading a tenant context | 139 |
37+
| Threading a tenant context | 141 |
3838
| Provably carrying none | 17 |
3939
| …and decidably tenancy-enabled | 9 |
4040
| Options argument unreadable | 67 |
4141
| …and decidably tenancy-enabled | 32 |
42-
| Threading a decidably elevated context | 104 |
42+
| Threading a decidably elevated context | 106 |
4343
| Threading a decidably non-elevated context | 0 |
4444
| Threading a context of undecidable elevation | 102 |
4545

@@ -52,19 +52,21 @@ holds still. They are required to be HERE and to say WHEN they were true;
5252
their values are not compared. The reasoning, and the measurement behind it,
5353
are in `scripts/check-tenant-audit-census.mjs`.
5454

55-
Measured on 2026-09-14 at `d4554d4f5`.
55+
Measured on 2026-09-16 at `11daf7f69`.
5656

5757
| corpus scale (not enforced) | count |
5858
| :--- | ---: |
59-
| tracked non-test sources scanned | 568 |
60-
| engine-shaped types recognised | 61 |
59+
| tracked non-test sources scanned | 570 |
60+
| engine-shaped types recognised | 63 |
6161
| declared objects in the registry | 117 |
62-
| same-named calls subtracted as non-engine | 140 |
62+
| same-named calls subtracted as non-engine | 146 |
6363

6464
## Every site
6565

6666
| file | verb | object | tenancy | tenant context | n |
6767
|---|---|---|---|---|---:|
68+
| `packages/plugins/organizations/src/claim-org-seed-ownership.ts` | `update` | `schema.name` | undecidable | elevated | 1 |
69+
| `packages/plugins/organizations/src/claim-orphan-org-rows.ts` | `update` | `schema.name` | undecidable | elevated | 1 |
6870
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `update` | `object` | undecidable | context, elevation undecidable | 1 |
6971
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `insert` | `sys_approval_action` | enabled | elevated | 14 |
7072
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `delete` | `sys_approval_approver` | enabled | elevated | 2 |

‎packages/plugins/organizations/src/claim-org-seed-ownership.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323

2424
import type { ServiceObject } from '@objectstack/spec/data';
2525

26+
import type { OrgScopingEngine } from './org-scoping-engine.js';
27+
2628
interface ClaimOwnershipOptions {
2729
logger?: {
2830
info: (message: string, meta?: Record<string, any>) => void;
@@ -49,15 +51,15 @@ function hasField(schema: ServiceObject, field: string): boolean {
4951
* and updates the org's unowned rows as `isSystem`. Returns a per-object summary.
5052
*/
5153
export async function claimOrgSeedOwnership(
52-
ql: any,
54+
ql: OrgScopingEngine,
5355
organizationId: string,
5456
ownerUserId: string,
5557
options: ClaimOwnershipOptions = {},
5658
): Promise<{ object: string; count: number }[]> {
5759
const logger = options.logger;
5860
if (!organizationId || !ownerUserId) return [];
5961
if (!ql || typeof ql.update !== 'function' || typeof ql.find !== 'function') return [];
60-
const registry = (ql as any).registry;
62+
const registry = ql.registry;
6163
if (!registry || typeof registry.getAllObjects !== 'function') {
6264
logger?.warn?.('[org-scoping] claimOrgSeedOwnership: registry unavailable');
6365
return [];

‎packages/plugins/organizations/src/claim-orphan-org-rows.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@
2828

2929
import type { ServiceObject } from '@objectstack/spec/data';
3030

31+
import type { OrgScopingEngine } from './org-scoping-engine.js';
32+
3133
interface ClaimOptions {
3234
logger?: {
3335
info: (message: string, meta?: Record<string, any>) => void;
@@ -58,15 +60,15 @@ function hasOrganizationField(schema: ServiceObject): boolean {
5860
* Returns a per-object summary `{ object, count }[]`.
5961
*/
6062
export async function claimOrphanOrgRows(
61-
ql: any,
63+
ql: OrgScopingEngine,
6264
organizationId: string,
6365
options: ClaimOptions = {},
6466
): Promise<{ object: string; count: number }[]> {
6567
const logger = options.logger;
6668
if (!ql || typeof ql.update !== 'function' || typeof ql.find !== 'function') {
6769
return [];
6870
}
69-
const registry = (ql as any).registry;
71+
const registry = ql.registry;
7072
if (!registry || typeof registry.getAllObjects !== 'function') {
7173
logger?.warn?.('[org-scoping] claimOrphanOrgRows: registry unavailable');
7274
return [];
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The ObjectQL doors the org-scoping back-fills reach through, named instead of
5+
* erased.
6+
*
7+
* Both back-fills (`claimOrphanOrgRows`, `claimOrgSeedOwnership`) used to take
8+
* `ql: any`. That is not a style preference in this corpus: the tenant-audit
9+
* census reads the RECEIVER's declared type to decide whether a write call site
10+
* is an engine write at all, and an `any` receiver has no type to read. Sites it
11+
* cannot place are reported as `unledgered` -- an error, never a default,
12+
* because a write it cannot see is a write the tenant-audit population does not
13+
* certify. Naming the doors here places both sites by their TYPE, which is the
14+
* one placement route that needs no ledger row.
15+
*
16+
* ⛔ Deliberately narrow, following `OrphanCleanupEngine` in `plugin-sharing`:
17+
* it declares only what these two functions call, so it cannot drift into a
18+
* second, competing description of the whole engine. Widen it by adding the door
19+
* you actually use, never by re-exporting the engine interface.
20+
*
21+
* ⛔ And deliberately PACKAGE-PRIVATE -- the same restraint one layer out. The
22+
* census reads the type declared at the RECEIVER, in this source tree; it never
23+
* reads the package's public entry, so exporting this bought the placement
24+
* nothing and only widened a published surface. ⛔ Do not add it to `index.ts`.
25+
*/
26+
27+
import type { ServiceObject } from '@objectstack/spec/data';
28+
29+
export interface OrgScopingEngine {
30+
find(object: string, query: any, options?: any): Promise<any>;
31+
update(object: string, data: any, options?: any): Promise<any>;
32+
/**
33+
* Optional on purpose. "registry unavailable" is a real, tested, logged no-op
34+
* path in both back-fills -- a caller handing over an engine without one gets
35+
* an empty result and a warning, not a throw -- so the type must be able to
36+
* describe that engine rather than forcing the guard to be dead code.
37+
*/
38+
registry?: { getAllObjects(): ServiceObject[] };
39+
}

‎packages/plugins/organizations/src/organizations-plugin.ts‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { Plugin, PluginContext } from '@objectstack/core';
44
import { claimOrphanOrgRows } from './claim-orphan-org-rows.js';
5+
import type { OrgScopingEngine } from './org-scoping-engine.js';
56
import { isDefaultOrganizationBootstrapTrigger } from '@objectstack/plugin-auth';
67
import { ensureDefaultOrganization } from './ensure-default-organization.js';
78
import { assertWalledMembershipPolicyDeclared } from './membership-policy-gate.js';
@@ -66,13 +67,18 @@ export interface OrganizationsPluginOptions {
6667
* repository types its lookups rather than inheriting a grandfather clause it is
6768
* not on. Structural rather than the engine's full contract for the same reason
6869
* `membership-policy-gate.ts` states about ITS probes: this plugin needs three
69-
* members, the `catch` arms below already treat every one of them as possibly
70-
* absent, and naming the whole engine interface here would claim a coupling the
71-
* runtime checks do not make.
70+
* members, and the `catch` arms below already treat every one of them as
71+
* possibly absent.
72+
*
73+
* It extends `OrgScopingEngine` because this plugin does not only CALL the slot,
74+
* it FORWARDS it: `claimOrphanOrgRows(ql, ...)` below writes through this very
75+
* value. While that parameter was `any` the forwarded doors were a coupling the
76+
* types did not state and the tenant-audit census could not read. Naming them
77+
* here is the narrow claim -- only the doors that are actually forwarded, not
78+
* the engine's full contract.
7279
*/
73-
interface OrgScopingQuerySlot {
80+
interface OrgScopingQuerySlot extends OrgScopingEngine {
7481
registerMiddleware(mw: (opCtx: any, next: () => Promise<void>) => Promise<void>): void;
75-
find(object: string, query: unknown, options?: unknown): Promise<any>;
7682
getSchema?(object: string): any;
7783
}
7884

0 commit comments

Comments
 (0)