From 737b106f40b8d51593e1ee94191f061063903b03 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 20:58:33 +0000 Subject: [PATCH 1/5] fix(lint): give the tenant-audit census refusals an exit to CI `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 that matches no call). The generator's own `main()` prints both and exits 1, but `lint.yml` invokes the GATE and never the generator, and the gate read neither field. The census could find an unplaceable receiver, print nothing anyone runs, and `Lint & Repo Gates` stayed green -- a defect arriving as compliance, in the one direction the census and the published page both say is an error and never a default. Add check C, `censusRefusals()`, reading those two fields off the census the gate already holds. No second walk of the corpus: the read costs nothing and the fix stays inside the gate file. Kept out of `checkPage()` so the self-test can drive it with synthetic censuses in both directions regardless of what today's tree holds, with a control that a census placing every site is clean. Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk Co-authored-by: Claude --- scripts/check-tenant-audit-census.mjs | 124 ++++++++++++++++++++++++-- 1 file changed, 118 insertions(+), 6 deletions(-) diff --git a/scripts/check-tenant-audit-census.mjs b/scripts/check-tenant-audit-census.mjs index 7789eb3791c..e5102459e1c 100644 --- a/scripts/check-tenant-audit-census.mjs +++ b/scripts/check-tenant-audit-census.mjs @@ -45,7 +45,7 @@ * no case where it must guess. That is the property the anchor scheme cannot * have, and it is why this gate is not a port of its sibling. * - * ## The two checks + * ## The checks * * A DRIFT both generated artefacts equal what the generator produces * from the tree right now, byte-for-byte: the page's generated @@ -64,6 +64,15 @@ * are hand-written, and a hand-written number is exactly the * thing that goes stale first. Its corpus-scale counterpart is * `UNENFORCED_PROSE_COUNTS`: required to be said, not to be right. + * C CENSUS the census this gate just ran placed every write call site it + * found, and every `UNTYPED_RECEIVERS` row still matches a call. + * Those are facts about the TREE rather than about the artefacts + * and they arrive on the census object itself, as `unledgered` + * and `staleLedgerRows`. CI runs THIS gate and never the + * generator, so until check C existed the generator's own exit 1 + * on an unplaceable receiver reached nobody: the census could + * find one, say so to no one, and `Lint & Repo Gates` stayed + * green. See `censusRefusals`. * * ## ⭐ What is enforced, and what is deliberately not * @@ -89,7 +98,9 @@ * A page that cannot be read, a missing marker pair, a census with zero sites, a * prose pattern that matches nothing, and any refusal the generator itself * raises (an unplaceable receiver, a stale ledger row, an unparseable source) - * are all exit 1 naming what could not be read. + * are all exit 1 naming what could not be read. The first two of those reach the + * exit code through `censusRefusals` (check C); the third throws out of + * `runCensus()` before any check runs. */ import { readFileSync } from 'node:fs'; @@ -125,12 +136,13 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'A DRIFT': 5, 'B PROSE': 4, '⭐ THE SPLIT': 9, + 'census refusals': 5, 'refusals': 1, }); // DELETING an entry silences that battery's floor exactly as effectively as // zeroing it, so the roster's own size is pinned too. -const SELF_TEST_BATTERY_FLOOR = 4; +const SELF_TEST_BATTERY_FLOOR = 5; // The key an assertion is filed under when no battery is open. It is not a // declared battery, so it reds by the same set difference rather than silently @@ -573,7 +585,62 @@ function firstDifference(committed, expected) { } /** - * Run both checks against a census and a page text. + * ⭐ Check C -- the refusals the CENSUS raises, given an exit. + * + * `runCensus()` reports two failures about the TREE rather than about the + * artefacts: a write call site whose receiver is erased and that none of the + * three placement rules reaches (`census.unledgered`), and an `UNTYPED_RECEIVERS` + * row that no longer matches any call in the corpus (`census.staleLedgerRows`). + * + * The generator's own `main()` prints both and exits 1 -- but `lint.yml` invokes + * THIS gate and never the generator, so until this function existed those two + * fields were read by nothing on the way to a CI verdict. The census could find + * an unplaceable receiver, print nothing anyone runs, and the job stayed green: + * a defect arriving as COMPLIANCE, which is the one direction the census says it + * cannot survive being wrong in (`tenant-audit-census.mjs`, the rule the page + * publishes verbatim: *"An unreadable receiver that none of the three place is an + * ERROR, never a default."*). + * + * ⛔ The repair is NOT to add `node scripts/tenant-audit-census.mjs` beside this + * gate in the workflow: that walks the same corpus and builds the same AST a + * second time for one verdict this gate already holds in its hand. What was + * missing is the READ, and the read costs nothing. + * + * ⚠️ Deliberately NOT folded into `checkPage`. That function certifies the two + * ARTEFACTS against a census and takes its page as text so the self-test can feed + * it adversarial fixtures; this one certifies the CENSUS, and its fixtures are + * synthetic censuses. Keeping them apart is what lets the self-test instrument + * check C in both directions no matter what today's tree happens to hold -- a + * tree with an unplaceable receiver would otherwise turn `--self-test` red on its + * "the committed artefacts are clean" case and take every other case's verdict + * with it. + * + * @returns {string[]} problems, empty when the census placed every site it found. + */ +export function censusRefusals(census) { + const problems = []; + for (const u of census.unledgered ?? []) { + problems.push( + `[untyped-receiver] ${u.file}:${u.line} \`${u.receiver}\`.${u.verb}() -- receiver type ` + + `unreadable [${u.how}] and the object name is not a literal declared object. Nothing ` + + 'places this site, so the census cannot say whether it is an engine write at all and the ' + + 'page certifies a population with a hole in it. Rule on what the receiver is, then add an ' + + '`UNTYPED_RECEIVERS` row in `scripts/tenant-audit-census.mjs` saying so.', + ); + } + for (const r of census.staleLedgerRows ?? []) { + problems.push( + `[stale-ledger-row] UNTYPED_RECEIVERS names ${r.file} (receiver \`${r.receiver}\`) but no ` + + 'such write call exists -- delete the row. A row that matches nothing still EXCUSES the ' + + 'next site that grows into its (file, receiver) pair, so a stale row is an exemption ' + + 'nobody can see.', + ); + } + return problems; +} + +/** + * Run both artefact checks against a census and a page text. * * Takes the page as TEXT rather than reading it, so the self-test can feed * adversarial pages through the same code path the production run uses. @@ -863,6 +930,48 @@ export function selfTest() { check(page.replace(declaredObjectsSentence, 'Across the declared objects')) .some((p) => p.startsWith('[unenforced-prose-missing]'))); + // ── census refusals (check C) ────────────────────────────────────────────── + // ⭐ The direction the ARTEFACTS cannot show at all: these two fields are facts + // about the TREE, and a gate that never reads them prints a success line while + // the census it just ran says it could not place a site. Driven with SYNTHETIC + // censuses, so the rule stays instrumented whatever today's tree holds -- and + // in BOTH directions, because a `censusRefusals` that always returned `[]` + // would look identical to this self-test on a census that happens to be clean. + battery('census refusals'); + const unplaceableSite = { + file: 'packages/services/service-fixture/src/seed.ts', + line: 42, + receiver: 'ql', + verb: 'update', + how: 'ql:any', + detail: 'any', + ledgered: false, + }; + const orphanedRow = { + file: 'packages/plugins/plugin-fixture/src/departed.ts', + receiver: 'engine', + engine: true, + what: 'a ledger row whose write call left the tree', + }; + const refuse = (unledgered, staleLedgerRows) => + censusRefusals({ ...census, unledgered, staleLedgerRows }); + + t('a census that could not place a receiver is a finding', + refuse([unplaceableSite], []).some((p) => p.startsWith('[untyped-receiver]'))); + t('the unplaceable-receiver refusal names the site it could not place', + refuse([unplaceableSite], []).some((p) => p.includes(`${unplaceableSite.file}:${unplaceableSite.line}`))); + t('a ledger row matching nothing in the tree is a finding', + refuse([], [orphanedRow]).some((p) => p.startsWith('[stale-ledger-row]'))); + t('the stale-row refusal names the row it could not match', + refuse([], [orphanedRow]).some((p) => p.includes(orphanedRow.file) && p.includes(orphanedRow.receiver))); + + // ⛔ ...and the control, without which all four cases above are equally passed + // by a function that simply reports everything it is handed: a census that + // placed every site and matched every row is clean. + t('⭐ CONTROL: a census with neither an unplaceable site nor a stale row is NOT a finding', + refuse([], []).length === 0, + refuse([], []).join(' | ')); + // ── refusals ─────────────────────────────────────────────────────────────── battery('refusals'); t('an empty census refuses rather than certifying the artefacts', @@ -933,7 +1042,9 @@ function main(argv) { } const census = runCensus(); - const problems = checkPage(census, page, counts); + // ⭐ Check C first: `checkPage` certifies the artefacts AGAINST this census, so + // a census that could not place one of its own sites is the prior question. + const problems = [...censusRefusals(census), ...checkPage(census, page, counts)]; for (const p of problems) console.error(`::error::${p}`); if (problems.length > 0) { @@ -945,7 +1056,8 @@ function main(argv) { `✓ check-tenant-audit-census: OK -- ${t.writeCallSites} write call sites certified ` + `(${t.staticallyDecidableObjectName} decidable; ${t.tenancyEnabledProvablyNoContext} tenancy-enabled ` + `sites PROVABLY carry no tenant context, ${t.tenancyEnabledContextUnreadable} more unreadable), ` - + `${PROSE_COUNTS.length} prose figures held to the census.`, + + `${PROSE_COUNTS.length} prose figures held to the census, ` + + 'every write call site placed and every `UNTYPED_RECEIVERS` row matched.', ); return 0; } From 11daf7f69093f6d6732eb139f004031d67b4c8e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 21:34:07 +0000 Subject: [PATCH 2/5] fix(organizations): name the ObjectQL doors the org-scoping back-fills reach through `claimOrphanOrgRows` and `claimOrgSeedOwnership` both took `ql: any`, so the tenant-audit census had no receiver type to read and reported both write call sites as unledgered -- an error, never a default. Declare the narrow `OrgScopingEngine` interface these two functions actually call (following `OrphanCleanupEngine` in plugin-sharing), annotate both parameters with it, and drop the two `(ql as any).registry` casts it makes unnecessary. Placing the sites by TYPE needs no `UNTYPED_RECEIVERS` row: that ledger is documented shrink-only and keyed by (file, receiver), so growing it by two rows runs against its own discipline. `scripts/tenant-audit-census.mjs` is untouched. The certified population moves 223 to 225 and both sites read as elevated (`context: SYSTEM_CTX`), so the generated region and the audit ledger are regenerated with `--write` and the page's eight hand-written prose figures are restated to match. Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk Co-authored-by: Claude --- .../docs/permissions/tenant-audit-census.mdx | 32 ++++++++--------- ...08-tenant-audit-write-call-sites.counts.md | 18 +++++----- .../src/claim-org-seed-ownership.ts | 6 ++-- .../src/claim-orphan-org-rows.ts | 6 ++-- packages/plugins/organizations/src/index.ts | 4 +++ .../organizations/src/org-scoping-engine.ts | 34 +++++++++++++++++++ 6 files changed, 72 insertions(+), 28 deletions(-) create mode 100644 packages/plugins/organizations/src/org-scoping-engine.ts diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index 9a4dd57c443..7c4b7f0d6ed 100644 --- a/content/docs/permissions/tenant-audit-census.mdx +++ b/content/docs/permissions/tenant-audit-census.mdx @@ -106,7 +106,7 @@ are reported as `undecidable` rather than assumed either way. The same holds twice over for the context. An options argument spelled as a literal can be read; one spelled `options`, `{ ...opts }`, or handed through a -forwarding shim cannot, and **67 of the 223 sites are spelled that way**. A +forwarding shim cannot, and **67 of the 225 sites are spelled that way**. A context resolved from an inline literal or a local `const` can be tested for `isSystem`; one arriving from a helper call cannot. @@ -155,10 +155,10 @@ reproduce them. Where it disagrees, it disagrees on the page: | carried figure | where it survives | this census | | :--- | :--- | ---: | -| 175 write call sites | quoted in the merged changeset | **223** | +| 175 write call sites | quoted in the merged changeset | **225** | | 24 carrying no tenant context | quoted in the merged changeset | **9** provable and tenancy-enabled; **32** more whose options argument is unreadable | -| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **149 of 223** decidable, **74** undecidable | -| 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 | +| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **149 of 225** decidable, **76** undecidable | +| 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 | | 141 and 132, two independent re-derivations | the card that filed this work | — | **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. The fourth row is the one worth flagging to anyone citing it. **The 135 / 77% figure has no surviving corroboration anywhere in the tree.** This census reads -104 of 223 (47%) as decidably elevated, with 102 more whose elevation is a +106 of 225 (47%) as decidably elevated, with 102 more whose elevation is a run-time fact — so the claim is neither confirmed nor refuted, and the honest answer is that a static reading cannot settle it. -⇒ **Cite `9 / 223`, and say what it is**: the sites whose options argument was +⇒ **Cite `9 / 225`, and say what it is**: the sites whose options argument was READ and holds no tenant context, against a decidably tenancy-enabled object. That is the control's provable yield surface. ⛔ Do not cite it as "the sites without tenant context" — **32 further sites** have an options argument this @@ -191,23 +191,23 @@ cannot read, and they are neither in nor out. | what | count | | :--- | ---: | -| write call sites on the application surface | **223** | +| write call sites on the application surface | **225** | | …whose object name is statically decidable | 149 | -| …whose object name is chosen at run time | 74 | +| …whose object name is chosen at run time | 76 | | …against an object with tenancy ENABLED | 149 | | …against an object that declares tenancy off | 0 | -| threading a tenant context | 139 | +| threading a tenant context | 141 | | PROVABLY carrying none (options read, no context key) | **17** | | …of those, against a decidably tenancy-enabled object | **9** | | options argument UNREADABLE — may or may not carry one | 67 | | …of those, against a decidably tenancy-enabled object | 32 | -| threading a decidably ELEVATED (`isSystem`) context | 104 | +| threading a decidably ELEVATED (`isSystem`) context | 106 | | threading a context that is decidably NOT elevated | 0 | | threading a context whose elevation is a run-time fact | 102 | | how the instrument reached the site | count | | :--- | ---: | -| receiver carried a readable engine type | 179 | +| receiver carried a readable engine type | 181 | | receiver erased, placed by the object NAME | 18 | | receiver erased, placed by an `object: string` PARAMETER | 15 | | receiver erased, placed by an `UNTYPED_RECEIVERS` row | 11 | @@ -215,7 +215,7 @@ cannot read, and they are neither in nor out. | object name spelled inline | 109 | | object name spelled through a `const` | 40 | | object name is an `object: string` parameter | 19 | -| object name is some other run-time expression | 55 | +| object name is some other run-time expression | 57 | The corpus walked is every tracked non-test source under `packages/services/` 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; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-09-14 at `d4554d4f5`. +Measured on 2026-09-16 at `737b106f4`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 568 | -| engine-shaped types recognised | 61 | +| tracked non-test sources scanned | 570 | +| engine-shaped types recognised | 62 | | declared objects in the registry | 117 | -| same-named calls subtracted as non-engine | 140 | +| same-named calls subtracted as non-engine | 146 | {/* END GENERATED: tenant-audit-census */} diff --git a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md index 8732a773be7..03d96e4b050 100644 --- a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md +++ b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md @@ -29,17 +29,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution. | Measure | Value | |---|---:| -| Write call sites | 223 | +| Write call sites | 225 | | Object name statically decidable | 149 | -| Object name chosen at run time | 74 | +| Object name chosen at run time | 76 | | Against a tenancy-enabled object | 149 | | Against an object declaring tenancy off | 0 | -| Threading a tenant context | 139 | +| Threading a tenant context | 141 | | Provably carrying none | 17 | | …and decidably tenancy-enabled | 9 | | Options argument unreadable | 67 | | …and decidably tenancy-enabled | 32 | -| Threading a decidably elevated context | 104 | +| Threading a decidably elevated context | 106 | | Threading a decidably non-elevated context | 0 | | Threading a context of undecidable elevation | 102 | @@ -52,19 +52,21 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-09-14 at `d4554d4f5`. +Measured on 2026-09-16 at `737b106f4`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 568 | -| engine-shaped types recognised | 61 | +| tracked non-test sources scanned | 570 | +| engine-shaped types recognised | 62 | | declared objects in the registry | 117 | -| same-named calls subtracted as non-engine | 140 | +| same-named calls subtracted as non-engine | 146 | ## Every site | file | verb | object | tenancy | tenant context | n | |---|---|---|---|---|---:| +| `packages/plugins/organizations/src/claim-org-seed-ownership.ts` | `update` | `schema.name` | undecidable | elevated | 1 | +| `packages/plugins/organizations/src/claim-orphan-org-rows.ts` | `update` | `schema.name` | undecidable | elevated | 1 | | `packages/plugins/plugin-approvals/src/approval-service.ts` | `update` | `object` | undecidable | context, elevation undecidable | 1 | | `packages/plugins/plugin-approvals/src/approval-service.ts` | `insert` | `sys_approval_action` | enabled | elevated | 14 | | `packages/plugins/plugin-approvals/src/approval-service.ts` | `delete` | `sys_approval_approver` | enabled | elevated | 2 | diff --git a/packages/plugins/organizations/src/claim-org-seed-ownership.ts b/packages/plugins/organizations/src/claim-org-seed-ownership.ts index 3b8d03f67a5..323ec61dd54 100644 --- a/packages/plugins/organizations/src/claim-org-seed-ownership.ts +++ b/packages/plugins/organizations/src/claim-org-seed-ownership.ts @@ -23,6 +23,8 @@ import type { ServiceObject } from '@objectstack/spec/data'; +import type { OrgScopingEngine } from './org-scoping-engine.js'; + interface ClaimOwnershipOptions { logger?: { info: (message: string, meta?: Record) => void; @@ -49,7 +51,7 @@ function hasField(schema: ServiceObject, field: string): boolean { * and updates the org's unowned rows as `isSystem`. Returns a per-object summary. */ export async function claimOrgSeedOwnership( - ql: any, + ql: OrgScopingEngine, organizationId: string, ownerUserId: string, options: ClaimOwnershipOptions = {}, @@ -57,7 +59,7 @@ export async function claimOrgSeedOwnership( const logger = options.logger; if (!organizationId || !ownerUserId) return []; if (!ql || typeof ql.update !== 'function' || typeof ql.find !== 'function') return []; - const registry = (ql as any).registry; + const registry = ql.registry; if (!registry || typeof registry.getAllObjects !== 'function') { logger?.warn?.('[org-scoping] claimOrgSeedOwnership: registry unavailable'); return []; diff --git a/packages/plugins/organizations/src/claim-orphan-org-rows.ts b/packages/plugins/organizations/src/claim-orphan-org-rows.ts index 08eb36418c7..6af4cc3d277 100644 --- a/packages/plugins/organizations/src/claim-orphan-org-rows.ts +++ b/packages/plugins/organizations/src/claim-orphan-org-rows.ts @@ -28,6 +28,8 @@ import type { ServiceObject } from '@objectstack/spec/data'; +import type { OrgScopingEngine } from './org-scoping-engine.js'; + interface ClaimOptions { logger?: { info: (message: string, meta?: Record) => void; @@ -58,7 +60,7 @@ function hasOrganizationField(schema: ServiceObject): boolean { * Returns a per-object summary `{ object, count }[]`. */ export async function claimOrphanOrgRows( - ql: any, + ql: OrgScopingEngine, organizationId: string, options: ClaimOptions = {}, ): Promise<{ object: string; count: number }[]> { @@ -66,7 +68,7 @@ export async function claimOrphanOrgRows( if (!ql || typeof ql.update !== 'function' || typeof ql.find !== 'function') { return []; } - const registry = (ql as any).registry; + const registry = ql.registry; if (!registry || typeof registry.getAllObjects !== 'function') { logger?.warn?.('[org-scoping] claimOrphanOrgRows: registry unavailable'); return []; diff --git a/packages/plugins/organizations/src/index.ts b/packages/plugins/organizations/src/index.ts index d9fb083472f..44042dbdec4 100644 --- a/packages/plugins/organizations/src/index.ts +++ b/packages/plugins/organizations/src/index.ts @@ -63,6 +63,10 @@ export { OrganizationsPlugin as OrgScopingPlugin } from './organizations-plugin. export type { OrganizationsPluginOptions as OrgScopingPluginOptions } from './organizations-plugin.js'; export { claimOrphanOrgRows } from './claim-orphan-org-rows.js'; export { claimOrgSeedOwnership } from './claim-org-seed-ownership.js'; +// The receiver type both back-fills declare. Exported so a caller can name the +// engine it hands over -- and so the tenant-audit census can READ that receiver +// instead of reporting it as a site nothing places. +export type { OrgScopingEngine } from './org-scoping-engine.js'; // ⛔ No donor-org clone is exported, and none may be re-added (cloud#1345). // A new organization's rows come from the APP's own seed definitions // (`seed-datasets` / `seed-replayer`, replayed per tenant) or the organization diff --git a/packages/plugins/organizations/src/org-scoping-engine.ts b/packages/plugins/organizations/src/org-scoping-engine.ts new file mode 100644 index 00000000000..0353095e682 --- /dev/null +++ b/packages/plugins/organizations/src/org-scoping-engine.ts @@ -0,0 +1,34 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The ObjectQL doors the org-scoping back-fills reach through, named instead of + * erased. + * + * Both back-fills (`claimOrphanOrgRows`, `claimOrgSeedOwnership`) used to take + * `ql: any`. That is not a style preference in this corpus: the tenant-audit + * census reads the RECEIVER's declared type to decide whether a write call site + * is an engine write at all, and an `any` receiver has no type to read. Sites it + * cannot place are reported as `unledgered` -- an error, never a default, + * because a write it cannot see is a write the tenant-audit population does not + * certify. Naming the doors here places both sites by their TYPE, which is the + * one placement route that needs no ledger row. + * + * ⛔ Deliberately narrow, following `OrphanCleanupEngine` in `plugin-sharing`: + * it declares only what these two functions call, so it cannot drift into a + * second, competing description of the whole engine. Widen it by adding the door + * you actually use, never by re-exporting the engine interface. + */ + +import type { ServiceObject } from '@objectstack/spec/data'; + +export interface OrgScopingEngine { + find(object: string, query?: any, options?: any): Promise; + update(object: string, data: any, options?: any): Promise; + /** + * Optional on purpose. "registry unavailable" is a real, tested, logged no-op + * path in both back-fills -- a caller handing over an engine without one gets + * an empty result and a warning, not a throw -- so the type must be able to + * describe that engine rather than forcing the guard to be dead code. + */ + registry?: { getAllObjects(): ServiceObject[] }; +} From 058f50709cf6a23b4bf352835c1e599d630e0c4e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 21:45:07 +0000 Subject: [PATCH 3/5] fix(organizations): state the engine doors the plugin forwards to the back-fills `OrgScopingQuerySlot` named only the three members the plugin calls itself, but it also FORWARDS the slot: `claimOrphanOrgRows(ql, ...)` writes through that very value. While the back-fill's parameter was `any` that coupling was invisible to the types; with the parameter named it is a type error, which is the finding rather than an obstacle. Extend the slot from `OrgScopingEngine` so it states the doors it forwards, and require `query` on the engine's `find` so the slot's own stricter spelling satisfies it. Regenerate the census artefacts: the recognised engine-shaped type count moves 61 to 63 (corpus scale, dated and unenforced); the certified population is unchanged at 225. Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk Co-authored-by: Claude --- content/docs/permissions/tenant-audit-census.mdx | 4 ++-- ...26-08-tenant-audit-write-call-sites.counts.md | 4 ++-- .../organizations/src/org-scoping-engine.ts | 2 +- .../organizations/src/organizations-plugin.ts | 16 +++++++++++----- 4 files changed, 16 insertions(+), 10 deletions(-) diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index 7c4b7f0d6ed..6a42541c60c 100644 --- a/content/docs/permissions/tenant-audit-census.mdx +++ b/content/docs/permissions/tenant-audit-census.mdx @@ -232,12 +232,12 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-09-16 at `737b106f4`. +Measured on 2026-09-16 at `11daf7f69`. | corpus scale (not enforced) | count | | :--- | ---: | | tracked non-test sources scanned | 570 | -| engine-shaped types recognised | 62 | +| engine-shaped types recognised | 63 | | declared objects in the registry | 117 | | same-named calls subtracted as non-engine | 146 | diff --git a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md index 03d96e4b050..69505a01584 100644 --- a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md +++ b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md @@ -52,12 +52,12 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-09-16 at `737b106f4`. +Measured on 2026-09-16 at `11daf7f69`. | corpus scale (not enforced) | count | | :--- | ---: | | tracked non-test sources scanned | 570 | -| engine-shaped types recognised | 62 | +| engine-shaped types recognised | 63 | | declared objects in the registry | 117 | | same-named calls subtracted as non-engine | 146 | diff --git a/packages/plugins/organizations/src/org-scoping-engine.ts b/packages/plugins/organizations/src/org-scoping-engine.ts index 0353095e682..b483adbb73d 100644 --- a/packages/plugins/organizations/src/org-scoping-engine.ts +++ b/packages/plugins/organizations/src/org-scoping-engine.ts @@ -22,7 +22,7 @@ import type { ServiceObject } from '@objectstack/spec/data'; export interface OrgScopingEngine { - find(object: string, query?: any, options?: any): Promise; + find(object: string, query: any, options?: any): Promise; update(object: string, data: any, options?: any): Promise; /** * Optional on purpose. "registry unavailable" is a real, tested, logged no-op diff --git a/packages/plugins/organizations/src/organizations-plugin.ts b/packages/plugins/organizations/src/organizations-plugin.ts index 62c07176c8e..26cbdd31e3b 100644 --- a/packages/plugins/organizations/src/organizations-plugin.ts +++ b/packages/plugins/organizations/src/organizations-plugin.ts @@ -2,6 +2,7 @@ import { Plugin, PluginContext } from '@objectstack/core'; import { claimOrphanOrgRows } from './claim-orphan-org-rows.js'; +import type { OrgScopingEngine } from './org-scoping-engine.js'; import { isDefaultOrganizationBootstrapTrigger } from '@objectstack/plugin-auth'; import { ensureDefaultOrganization } from './ensure-default-organization.js'; import { assertWalledMembershipPolicyDeclared } from './membership-policy-gate.js'; @@ -66,13 +67,18 @@ export interface OrganizationsPluginOptions { * repository types its lookups rather than inheriting a grandfather clause it is * not on. Structural rather than the engine's full contract for the same reason * `membership-policy-gate.ts` states about ITS probes: this plugin needs three - * members, the `catch` arms below already treat every one of them as possibly - * absent, and naming the whole engine interface here would claim a coupling the - * runtime checks do not make. + * members, and the `catch` arms below already treat every one of them as + * possibly absent. + * + * It extends `OrgScopingEngine` because this plugin does not only CALL the slot, + * it FORWARDS it: `claimOrphanOrgRows(ql, ...)` below writes through this very + * value. While that parameter was `any` the forwarded doors were a coupling the + * types did not state and the tenant-audit census could not read. Naming them + * here is the narrow claim -- only the doors that are actually forwarded, not + * the engine's full contract. */ -interface OrgScopingQuerySlot { +interface OrgScopingQuerySlot extends OrgScopingEngine { registerMiddleware(mw: (opCtx: any, next: () => Promise) => Promise): void; - find(object: string, query: unknown, options?: unknown): Promise; getSchema?(object: string): any; } From cc7fdecb1dbbfb67bcf2bb8e418bdb188b7bb48c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 22:15:36 +0000 Subject: [PATCH 4/5] chore(changeset): record the org-scoping receiver typing The diff now moves published content: `@objectstack/organizations` ships `dist/` in its `files[]`, and `dist/index.d.ts` carries the narrowed `claimOrphanOrgRows(ql: OrgScopingEngine, ...)` signature plus the newly exported type. `skip-changeset` no longer applies. Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk Co-authored-by: Claude --- .../18211-org-scoping-engine-named-receiver.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .changeset/18211-org-scoping-engine-named-receiver.md diff --git a/.changeset/18211-org-scoping-engine-named-receiver.md b/.changeset/18211-org-scoping-engine-named-receiver.md new file mode 100644 index 00000000000..f3fe547f245 --- /dev/null +++ b/.changeset/18211-org-scoping-engine-named-receiver.md @@ -0,0 +1,13 @@ +--- +"@objectstack/organizations": minor +--- + +`claimOrphanOrgRows` and `claimOrgSeedOwnership` name the ObjectQL doors they write through — a new exported `OrgScopingEngine` type replaces `ql: any` on both, and `OrgScopingQuerySlot` states the doors the plugin forwards rather than only the three it calls itself (#18211). + +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. + +- **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`). +- **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. +- **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. +- **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. +- ⛔ **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. From 006f8f3ab7fb5ae3e0ae9ca8632628c43281a751 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 23:22:31 +0000 Subject: [PATCH 5/5] refactor(organizations): keep the org-scoping engine type package-private `index.ts` re-exported `OrgScopingEngine`, which widened the package's published surface for no gain: the tenant-audit census reads the type declared at the RECEIVER in this source tree and never the package entry, so the export bought the placement nothing. Removing it leaves the whole diff a narrowing. Measured after removal: the census still reports 225 write call sites with zero unresolved receivers, the gate still exits 0, and both regenerated artefacts are byte-unchanged. The emitted declarations still carry the interface inline, so a consumer's call resolves without ever naming it. The engine module now records the visibility choice next to the narrowness one, so the export is not re-added as a convenience. Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk Co-authored-by: Claude --- .changeset/18211-org-scoping-engine-named-receiver.md | 8 +++++--- packages/plugins/organizations/src/index.ts | 4 ---- packages/plugins/organizations/src/org-scoping-engine.ts | 5 +++++ 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/.changeset/18211-org-scoping-engine-named-receiver.md b/.changeset/18211-org-scoping-engine-named-receiver.md index f3fe547f245..83ef95ade22 100644 --- a/.changeset/18211-org-scoping-engine-named-receiver.md +++ b/.changeset/18211-org-scoping-engine-named-receiver.md @@ -2,12 +2,14 @@ "@objectstack/organizations": minor --- -`claimOrphanOrgRows` and `claimOrgSeedOwnership` name the ObjectQL doors they write through — a new exported `OrgScopingEngine` type replaces `ql: any` on both, and `OrgScopingQuerySlot` states the doors the plugin forwards rather than only the three it calls itself (#18211). +`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). + +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. 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. - **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`). -- **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. +- **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. - **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. -- **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. +- **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. - ⛔ **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. diff --git a/packages/plugins/organizations/src/index.ts b/packages/plugins/organizations/src/index.ts index 44042dbdec4..d9fb083472f 100644 --- a/packages/plugins/organizations/src/index.ts +++ b/packages/plugins/organizations/src/index.ts @@ -63,10 +63,6 @@ export { OrganizationsPlugin as OrgScopingPlugin } from './organizations-plugin. export type { OrganizationsPluginOptions as OrgScopingPluginOptions } from './organizations-plugin.js'; export { claimOrphanOrgRows } from './claim-orphan-org-rows.js'; export { claimOrgSeedOwnership } from './claim-org-seed-ownership.js'; -// The receiver type both back-fills declare. Exported so a caller can name the -// engine it hands over -- and so the tenant-audit census can READ that receiver -// instead of reporting it as a site nothing places. -export type { OrgScopingEngine } from './org-scoping-engine.js'; // ⛔ No donor-org clone is exported, and none may be re-added (cloud#1345). // A new organization's rows come from the APP's own seed definitions // (`seed-datasets` / `seed-replayer`, replayed per tenant) or the organization diff --git a/packages/plugins/organizations/src/org-scoping-engine.ts b/packages/plugins/organizations/src/org-scoping-engine.ts index b483adbb73d..df75a7dcb7a 100644 --- a/packages/plugins/organizations/src/org-scoping-engine.ts +++ b/packages/plugins/organizations/src/org-scoping-engine.ts @@ -17,6 +17,11 @@ * it declares only what these two functions call, so it cannot drift into a * second, competing description of the whole engine. Widen it by adding the door * you actually use, never by re-exporting the engine interface. + * + * ⛔ And deliberately PACKAGE-PRIVATE -- the same restraint one layer out. The + * census reads the type declared at the RECEIVER, in this source tree; it never + * reads the package's public entry, so exporting this bought the placement + * nothing and only widened a published surface. ⛔ Do not add it to `index.ts`. */ import type { ServiceObject } from '@objectstack/spec/data';