diff --git a/.changeset/platform-object-tenancy-census-derived.md b/.changeset/platform-object-tenancy-census-derived.md
new file mode 100644
index 0000000000..7855601bcb
--- /dev/null
+++ b/.changeset/platform-object-tenancy-census-derived.md
@@ -0,0 +1,13 @@
+---
+"@objectstack/objectql": patch
+---
+
+The platform-object tenancy census is derived and gated instead of hand-written in a comment. Documentation only — no runtime behaviour changes.
+
+`PLATFORM_OBJECT_TENANCY`'s header explained why the reclassification needs a ledger rather than a schema read, and backed the argument with three hand-written digits and a parenthetical attributing them. Nothing re-derived any of it, so it was true only until the population moved and failed silently when it did — in both of the directions a prose count can.
+
+The parenthetical mis-attributed the exclusion: it named `sys_sso_provider`'s `tenancy.enabled: false` as an addition to the `managedBy: 'better-auth'` set that object was already in, and left `sys_api_key`'s identical opt-out unnamed. The arithmetic stayed right, which is why no reader and no gate caught it — a wrong reason producing a right total is the shape that survives longest. The digits then went stale when an object opted out of the tenant column through a third mechanism the parenthetical's taxonomy had no slot for (`systemFields: { tenant: false }`), while the gated page next door was updated in the same commit.
+
+The digits and the parenthetical are deleted rather than corrected. The header now points at `scripts/platform-object-tenancy-census.json` and states the PREDICATE it was missing: an object is inside the machinery when `resolveTenantFieldName` answers non-null on the **registered** schema — after `applySystemFields` has injected the tenant column, because the injected column is what the engine sees, not what the author typed. Counting `managedBy` as if the resolver read it is the mistake that produced the wrong reason.
+
+The artefact is derived by `scripts/platform-object-tenancy-census.mjs`, which loads `resolveTenantFieldName` and `resolveInjectedSystemColumns` from source and executes them rather than re-spelling what they decide, and is held to the tree by `scripts/check-platform-object-tenancy-census.mjs`. It records per object the declaration on that object's own schema that puts it outside the reach; declarations are not mutually exclusive and an object carrying two keeps both. An excluded object with no declared mechanism is an error, not a default: the generator refuses to commit the row and the gate reds, so a new exclusion mechanism is adjudicated rather than absorbed into an existing total.
diff --git a/.gitattributes b/.gitattributes
index 294889e31f..678a8be9fc 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -154,3 +154,4 @@ content/docs/references/** merge=os-regen
content/docs/permissions/system-context.mdx merge=os-regen
skills/*/references/_index.md merge=os-regen
skills/objectstack-ui/references/react-blocks.md merge=os-regen
+scripts/platform-object-tenancy-census.json merge=os-regen
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index a40765563e..bd68cff43e 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -1593,6 +1593,54 @@ jobs:
node scripts/check-tenant-audit-census.mjs --self-test
node scripts/check-tenant-audit-census.mjs
+ # #14957 platform-object tenancy census.
+ # `scripts/platform-object-tenancy-census.json` carries which
+ # platform-namespace objects the tenancy machinery can REACH, and the
+ # declaration on each excluded object's own schema that puts it outside.
+ #
+ # ⭐ It exists because the same census was PROSE, in the header of
+ # `packages/objectql/src/tenancy/platform-object-tenancy.ts`, and failed in
+ # both available directions inside one month. First a wrong REASON behind a
+ # right total: the parenthetical named `sys_sso_provider` as an addition to
+ # the `managedBy: 'better-auth'` set it was already in, and `24 + 1 = 25`
+ # stayed right, which is why neither a reader nor a gate caught it — that
+ # count reached a PR body and a filed card, and two independent
+ # re-measurements were spent proving a correct file correct. Then a stale
+ # TOTAL: PR #15155 declared `systemFields: { tenant: false }` on
+ # `sys_metadata_activation`, the object left the machinery's reach, and the
+ # ungated prose kept the old digits while the same commit updated the GATED
+ # page next door. CI was green throughout both.
+ #
+ # ⚠️ The PREDICATE is the deliverable and the number is a by-product:
+ # `resolveTenantFieldName` answering non-null on the REGISTERED schema
+ # (after `applySystemFields` injects the tenant column). A gate that
+ # re-derives the number without stating the predicate would recreate the
+ # same trap with fresher digits, and `managedBy` — the thing the prose
+ # counted as if the resolver read it — is not the predicate.
+ #
+ # ⭐ Its third verdict is the one worth having: an object outside the reach
+ # with NO declared mechanism to explain it is an ERROR, not a default. That
+ # is what fires on a new exclusion mechanism the day it lands, instead of
+ # letting it be absorbed into an existing bucket's total.
+ #
+ # It is a DRIFT gate, not an anchor gate: the artefact carries no line
+ # numbers, so displacement cannot move it, and there is exactly one
+ # mechanical repair path (`node scripts/platform-object-tenancy-census.mjs
+ # --write`). It lives in this job, with no paths filter, because the PRs
+ # that move this population are code-only ones that add or re-declare an
+ # object. The `--self-test` invocation is what `check:self-test-wired`
+ # requires and is the only instrument on this gate's two matching rules;
+ # a clean tree cannot tell a working rule from a weakened one.
+ #
+ # Invoked as `node` rather than through a `pnpm check:*` alias: see the
+ # GATE INVOCATION IDIOM note at the top of this file. TypeScript transpile
+ # of the predicate's import graph plus every tracked object declaration,
+ # no build and no spawns; ~10s.
+ - name: Platform-object tenancy census matches the tree
+ run: |
+ node scripts/check-platform-object-tenancy-census.mjs --self-test
+ node scripts/check-platform-object-tenancy-census.mjs
+
# #12236 one
per doc page. `DocsTitle` renders the frontmatter `title`
# as the page's unconditionally (apps/docs/app/[lang]/docs/[[...slug]]/
# page.tsx), so a body-level `# ` heading compiles to a SECOND one inside
diff --git a/package.json b/package.json
index 0f1b385b45..8839c470d7 100644
--- a/package.json
+++ b/package.json
@@ -157,7 +157,9 @@
"check:stall-guard-headroom": "node scripts/measure-stall-guard-headroom.mjs --self-test",
"check:swallow-census-controls": "node scripts/measure-durability-swallow-family.mjs --self-test=gated",
"gen:system-context-census": "node scripts/check-system-context-census.mjs --fix",
- "check:system-context-census": "node scripts/check-system-context-census.mjs --self-test && node scripts/check-system-context-census.mjs"
+ "check:system-context-census": "node scripts/check-system-context-census.mjs --self-test && node scripts/check-system-context-census.mjs",
+ "gen:platform-object-tenancy-census": "node scripts/platform-object-tenancy-census.mjs --write",
+ "check:platform-object-tenancy-census": "node scripts/check-platform-object-tenancy-census.mjs --self-test && node scripts/check-platform-object-tenancy-census.mjs"
},
"keywords": [
"objectstack",
diff --git a/packages/objectql/src/tenancy/platform-object-tenancy.ts b/packages/objectql/src/tenancy/platform-object-tenancy.ts
index c439d95cce..7df78dfa4b 100644
--- a/packages/objectql/src/tenancy/platform-object-tenancy.ts
+++ b/packages/objectql/src/tenancy/platform-object-tenancy.ts
@@ -26,18 +26,33 @@
* measurement is the reason this file exists. `applySystemFields`
* (`registry.ts`) provisions the tenant COLUMN unconditionally: its existence
* was deliberately decoupled from whether tenancy is on, so that sudo writers
- * can always stamp it. Measured on this tree by AST census of every
- * `ObjectSchema.create` in `packages/`:
- *
- * - 84 platform-namespace objects are registered in this repository;
- * - 25 resolve NO tenant field (24 `managedBy: 'better-auth'`, plus
- * `sys_sso_provider`'s `tenancy.enabled: false`) and are already outside
- * the machinery — they exit at `resolveTenantFieldName` returning null;
- * - **59 carry a tenant column**, `sys_permission_set` — #8672's own example
- * of a legitimately org-less object — among them.
- *
- * So a schema read admits 59 of 84 in one stroke, i.e. it replaces a wholesale
- * exemption with a wholesale inclusion. The ruling's classification source is
+ * can always stamp it.
+ *
+ * ⛔ The census that measures this is NOT restated here. It is derived by
+ * `scripts/platform-object-tenancy-census.mjs`, committed as
+ * `scripts/platform-object-tenancy-census.json`, and held to the tree by
+ * `scripts/check-platform-object-tenancy-census.mjs` (#14957). Its PREDICATE is
+ * the half this paragraph used to leave out: an object is inside the machinery
+ * when `resolveTenantFieldName` answers non-null on the registered schema —
+ * after `applySystemFields`, because the injected column is what the engine
+ * sees, not what the author typed. The artefact records, per object, the
+ * declaration on its OWN schema that puts it outside.
+ *
+ * ⛔ Do not restate a count here "for reference". This paragraph carried three
+ * hand-written digits and a parenthetical explaining them, and each half failed
+ * in its own direction. The parenthetical mis-attributed the exclusion — it
+ * named an object as an addition to the set it was already in — while its
+ * arithmetic stayed right, which is why nothing caught it. The digits then went
+ * stale when an object opted out of the tenant column (`systemFields: { tenant:
+ * false }`, a third mechanism the parenthetical's taxonomy had no slot for), and
+ * CI was green throughout. A number in this comment has no second party; the
+ * artefact has a gate.
+ *
+ * The ARGUMENT survives that measurement, and no digit was load-bearing for it:
+ * the great majority of platform-namespace objects carry the tenant column —
+ * `sys_permission_set`, #8672's own example of a legitimately org-less object,
+ * among them — so a schema read admits nearly all of them in one stroke, i.e.
+ * it replaces a wholesale exemption with a wholesale inclusion. The ruling's classification source is
* "有列**且有写手填**" — the column AND a writer that fills it — and the second
* half is not a runtime fact. It is a fact about the CODE, established once by
* inventory and written down here.
diff --git a/scripts/check-platform-object-tenancy-census.mjs b/scripts/check-platform-object-tenancy-census.mjs
new file mode 100644
index 0000000000..a70107823a
--- /dev/null
+++ b/scripts/check-platform-object-tenancy-census.mjs
@@ -0,0 +1,312 @@
+#!/usr/bin/env node
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * check-platform-object-tenancy-census -- the platform-object tenancy census is
+ * held to the tree, and an exclusion mechanism nobody has adjudicated reds
+ * rather than joining a total (#14957).
+ *
+ * node scripts/check-platform-object-tenancy-census.mjs
+ * node scripts/check-platform-object-tenancy-census.mjs --self-test
+ *
+ * ## The failure this exists for, in both of the directions it happened
+ *
+ * `packages/objectql/src/tenancy/platform-object-tenancy.ts` carried the census
+ * as PROSE: three digits and a parenthetical explaining them. Nothing re-derived
+ * it, so it was true only until the population moved, and it failed silently
+ * when it did -- twice, in the two different ways a hand-written count can:
+ *
+ * 1. A WRONG REASON behind a RIGHT total. The parenthetical read "24
+ * `managedBy: 'better-auth'`, plus `sys_sso_provider`'s `tenancy.enabled:
+ * false`". All 25 were `managedBy: 'better-auth'` and `sys_sso_provider`
+ * was one OF them; `sys_api_key` carries `tenancy.enabled: false` too and
+ * was not named. `24 + 1 = 25` is right, which is exactly why neither a
+ * reader nor a gate caught it. That count reached a PR body and a filed
+ * card, and two independent re-measurements were spent proving a correct
+ * file correct.
+ * 2. A RIGHT reason behind a STALE total. Commit efb3513178 (PR #15155, from
+ * #15024, 2026-09-04 04:37:18Z) declared `systemFields: { tenant: false }`
+ * on `sys_metadata_activation`. The object left the machinery's reach and
+ * 84 / 25 / 59 became 84 / 26 / 58. The same commit updated the GATED page
+ * next door and left the ungated prose alone, with CI green throughout.
+ *
+ * ⇒ Failure 2 also brought a THIRD exclusion mechanism into a taxonomy that had
+ * two, which is the shape this gate refuses hardest: see the third verdict.
+ *
+ * ## Three verdicts, and the third is the one worth having
+ *
+ * 1. **The artefact equals the tree.** A DRIFT check, deliberately not an anchor
+ * check: the artefact carries no line numbers, so a pure displacement cannot
+ * move it and there is exactly one mechanical repair path
+ * (`node scripts/platform-object-tenancy-census.mjs --write`).
+ * 2. **The header still points here.** Grain 1's fix was to DELETE the digits,
+ * so what is left to protect is the pointer and the predicate sentence. These
+ * are required to be PRESENT, not to be right -- a count is exactly what must
+ * not come back, and the way it comes back is by someone helpfully restating
+ * "for reference" what the artefact already says.
+ * 3. ⭐ **No unexplained exclusion.** An object the predicate puts outside the
+ * machinery for which no declared mechanism is found is an ERROR, never a
+ * default. This is the announce-never-absorb rule, and it is the verdict that
+ * would have fired on #15155's third mechanism the day it landed. It is the
+ * same shape `tenant-audit-census.mjs` gives an unplaceable receiver, for the
+ * same reason: a census that quietly widens a bucket to fit a new arrival
+ * publishes a total that no longer means what its predicate says.
+ *
+ * ## Why `--self-test` is not optional here
+ *
+ * Verdicts 1 and 3 are MATCHING RULES, and a matching rule cannot detect its own
+ * regression on a clean tree: green means the finding set is empty, weakening a
+ * rule can only shrink that set, and the empty set is the fixed point of
+ * shrinking. The production run reads identically before and after the rule
+ * breaks. `--self-test` supplies the adversarial inputs a clean tree by
+ * construction does not contain -- a mutated artefact, and an exclusion with no
+ * reason -- and is the only instrument watching either rule.
+ */
+
+import { readFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+import { isEntrypoint } from './invoked-as.mjs';
+import {
+ ARTEFACT,
+ EXCLUSION_REASONS,
+ HEADER,
+ PREDICATE_SOURCES,
+ renderArtefact,
+ runCensus,
+ selfTest as censusSelfTest,
+ unexplainedExclusions,
+} from './platform-object-tenancy-census.mjs';
+
+const ROOT = join(fileURLToPath(new URL('.', import.meta.url)), '..');
+
+/**
+ * The subtree spelling of this gate's population, for
+ * `scripts/pm/dispatch-gates.mjs` (the `ROOT_DIR_WATCH_HINTS` idiom).
+ *
+ * This gate's population is genuinely wide and genuinely bounded: every object
+ * DECLARATION in the tree decides a row (`packages/**\/*.object.ts`), the two
+ * predicate sources decide every row's verdict, the artefact is the compared
+ * side, and the header is verdict 2's subject. The first is a `packages`
+ * subtree walk and the last three are file literals this module and the
+ * generator it imports already spell -- the extractor reads those directly, so
+ * only the walk root needs declaring.
+ *
+ * ⛔ Nothing in this gate reads this array; it is provenance, never a lookup
+ * key. `--self-test` pins it against the roots actually walked, in both
+ * directions, so a declaration cannot drift into describing a population this
+ * gate does not have (which is worse than no declaration: it replaces a silent
+ * gate with a lying one).
+ */
+export const ROOT_DIR_WATCH_HINTS = ['packages/**'];
+
+/**
+ * Sentences the header must still carry.
+ *
+ * Required to be SAID, not to be right -- the tree itself is the authority on
+ * the numbers, and these strings are what keeps a reader who lands on the
+ * header from re-deriving a count by hand instead of running the census.
+ */
+export const HEADER_MARKERS = [
+ {
+ text: ARTEFACT,
+ why: 'the header must point at the derived artefact, since it no longer states the census itself',
+ },
+ {
+ text: 'resolveTenantFieldName',
+ why: 'the header must name the PREDICATE; a pointer to a number with no predicate recreates the trap with fresher digits',
+ },
+ {
+ text: 'registered schema',
+ why: 'the predicate is answered on the REGISTERED schema, and the authored/registered distinction is the half the original prose left out',
+ },
+];
+
+/** `--self-test` sets this only after printing its verdict; `main` reads it. */
+let selfTestReachedVerdict = false;
+
+export function run(root = ROOT) {
+ const findings = [];
+
+ const census = runCensus(root);
+
+ // Verdict 3 first: an unexplained exclusion makes every total below suspect,
+ // so it is reported as its own thing rather than as a diff line.
+ for (const name of unexplainedExclusions(census)) {
+ const row = census.objects.find((r) => r.name === name);
+ findings.push(
+ `[unexplained-exclusion] ${name} (${row.file}) is OUTSIDE the machinery's reach and no declared\n`
+ + ` mechanism explains it. The census knows: ${EXCLUSION_REASONS.map((r) => r.id).join(', ')}.\n`
+ + ' ⛔ Do NOT widen an existing reason to fit it. Adjudicate the new mechanism, then add it to\n'
+ + ' EXCLUSION_REASONS in scripts/platform-object-tenancy-census.mjs with the reasoning that admits it.',
+ );
+ }
+
+ // Verdict 1.
+ const want = renderArtefact(census);
+ const got = readFileSync(join(root, ARTEFACT), 'utf8');
+ if (got !== want) {
+ let committed = null;
+ try { committed = JSON.parse(got); } catch { /* reported as unparseable below */ }
+ const deltas = [];
+ if (committed && committed.totals) {
+ for (const key of ['registered', 'inReach', 'outOfReach']) {
+ if (committed.totals[key] !== census.totals[key]) {
+ deltas.push(`totals.${key}: committed ${committed.totals[key]} -> tree ${census.totals[key]}`);
+ }
+ }
+ const byName = new Map((committed.objects ?? []).map((r) => [r.name, r]));
+ for (const row of census.objects) {
+ const before = byName.get(row.name);
+ if (!before) { deltas.push(`+ ${row.name} (${row.reach}) is new to the population`); continue; }
+ if (before.reach !== row.reach) deltas.push(`~ ${row.name}: ${before.reach} -> ${row.reach}`);
+ else if (JSON.stringify(before.reasons) !== JSON.stringify(row.reasons)) {
+ deltas.push(`~ ${row.name} reasons: ${JSON.stringify(before.reasons)} -> ${JSON.stringify(row.reasons)}`);
+ }
+ }
+ const live = new Set(census.objects.map((r) => r.name));
+ for (const row of committed.objects ?? []) if (!live.has(row.name)) deltas.push(`- ${row.name} left the population`);
+ }
+ findings.push(
+ `[census-drift] ${ARTEFACT} no longer matches the tree.\n`
+ + (deltas.length > 0 ? `${deltas.map((d) => ` ${d}`).join('\n')}\n` : ' (the committed file could not be read as a census)\n')
+ + ' Repair: node scripts/platform-object-tenancy-census.mjs --write\n'
+ + ' ⛔ Do not hand-edit a digit to close this — the artefact is derived, and a hand-reconciled\n'
+ + ' number is the exact failure the census replaced.',
+ );
+ }
+
+ // Verdict 2.
+ const header = readFileSync(join(root, HEADER), 'utf8');
+ for (const marker of HEADER_MARKERS) {
+ if (!header.includes(marker.text)) {
+ findings.push(
+ `[header-pointer] ${HEADER} no longer contains "${marker.text}" — ${marker.why}.`,
+ );
+ }
+ }
+
+ return { findings, census };
+}
+
+export function selfTest(root = ROOT) {
+ let failures = 0;
+ const t = (what, got, want = true) => {
+ const ok = JSON.stringify(got) === JSON.stringify(want);
+ if (!ok) failures++;
+ console.log(` ${ok ? '✓' : '✗'} ${what}${ok ? '' : `\n got ${JSON.stringify(got)} want ${JSON.stringify(want)}`}`);
+ };
+
+ // The generator's battery runs FIRST and its result is folded in: this gate's
+ // verdict is an equality, and an equality between two identically-wrong sides
+ // holds. The classifiers on the derived side have no other instrument.
+ const generatorFailed = censusSelfTest(root) !== 0;
+ console.log('check-platform-object-tenancy-census');
+ t('the generator self-test passes', !generatorFailed);
+
+ const live = run(root);
+ t('the live tree is clean', live.findings, []);
+
+ // ⭐ POSITIVE CONTROL for verdict 1. A mutated artefact must red — and the
+ // mutation is a single digit, the smallest thing a drift can be.
+ {
+ const census = live.census;
+ const mutated = { ...census, totals: { ...census.totals, inReach: census.totals.inReach + 1 } };
+ t('a one-digit artefact mutation is NOT byte-equal to the fresh render',
+ renderArtefact(mutated) !== renderArtefact(census));
+ // The comparison itself, exercised without touching the file on disk.
+ t('…and the comparison the gate makes rejects it',
+ renderArtefact(mutated) !== readFileSync(join(root, ARTEFACT), 'utf8'));
+ }
+
+ // ⭐ POSITIVE CONTROL for verdict 3, the announce-never-absorb rule. The live
+ // tree has no unexplained exclusion (asserted above), so the adversarial row
+ // is supplied here.
+ {
+ const planted = {
+ ...live.census,
+ objects: [...live.census.objects, { name: 'sys_planted_probe', file: 'x.object.ts', reach: 'out', tenantField: null, reasons: [] }],
+ };
+ t('an out-of-reach row with no reason is reported', unexplainedExclusions(planted), ['sys_planted_probe']);
+ // The opposite direction, so the rule cannot be satisfied by reporting
+ // everything: a row WITH a reason is not an unexplained exclusion, and an
+ // in-reach row is never one however it is declared.
+ const explained = {
+ ...live.census,
+ objects: [
+ ...live.census.objects,
+ { name: 'sys_planted_explained', file: 'x.object.ts', reach: 'out', tenantField: null, reasons: ['systemFields: false'] },
+ { name: 'sys_planted_in_reach', file: 'x.object.ts', reach: 'in', tenantField: 'organization_id', reasons: [] },
+ ],
+ };
+ t('…and a reasoned exclusion, or an in-reach row, is not', unexplainedExclusions(explained), []);
+ }
+
+ // ⭐ POSITIVE CONTROL for verdict 2, both directions.
+ {
+ const header = readFileSync(join(root, HEADER), 'utf8');
+ t('the header carries every required marker', HEADER_MARKERS.filter((m) => !header.includes(m.text)).map((m) => m.text), []);
+ for (const marker of HEADER_MARKERS) {
+ const without = header.split(marker.text).join('');
+ t(`a header with "${marker.text}" removed fails its check`, !without.includes(marker.text));
+ }
+ }
+
+ // The dispatch-gates declaration, derived from what this gate really reads so
+ // that it cannot describe last month's population.
+ {
+ const walkRoots = ['packages'];
+ t('every walk root without a separator has the subtree spelling declared',
+ walkRoots.filter((r) => !r.includes('/')).every((r) => ROOT_DIR_WATCH_HINTS.includes(`${r}/**`)));
+ t('and it declares nothing this gate does not walk',
+ ROOT_DIR_WATCH_HINTS.every((h) => walkRoots.includes(h.replace(/\/\*+$/, ''))));
+ // The file literals the extractor reads directly, asserted to still BE
+ // literals in the module source rather than assembled at run time.
+ const selfSource = readFileSync(join(ROOT, 'scripts/check-platform-object-tenancy-census.mjs'), 'utf8')
+ + readFileSync(join(ROOT, 'scripts/platform-object-tenancy-census.mjs'), 'utf8');
+ for (const literal of [ARTEFACT, HEADER, PREDICATE_SOURCES.injectedColumns, PREDICATE_SOURCES.tenantFieldResolver]) {
+ t(`the family inherits the literal ${literal}`, selfSource.includes(literal));
+ }
+ }
+
+ console.log(failures === 0
+ ? `✓ check-platform-object-tenancy-census self-test: all checks pass (${live.census.totals.registered} objects, ${live.census.totals.outOfReach} outside the machinery)`
+ : `✗ check-platform-object-tenancy-census self-test: ${failures} check(s) failed`);
+ selfTestReachedVerdict = true;
+ return failures === 0 ? 0 : 1;
+}
+
+export function main(argv = []) {
+ if (argv.includes('--self-test')) {
+ const code = selfTest(ROOT);
+ if (!selfTestReachedVerdict) {
+ console.error(
+ '\n✗ check-platform-object-tenancy-census self-test: selfTest() returned without reaching its\n'
+ + ' verdict, so the exit code above describes nothing that was measured.',
+ );
+ return 1;
+ }
+ return code;
+ }
+
+ const { findings, census } = run(ROOT);
+ if (findings.length === 0) {
+ console.log(
+ `✓ platform-object tenancy census matches the tree: ${census.totals.registered} platform-namespace `
+ + `objects, ${census.totals.inReach} in the machinery's reach, ${census.totals.outOfReach} outside it, `
+ + 'every exclusion explained by a declaration on its own schema.',
+ );
+ return 0;
+ }
+ console.error('✗ platform-object tenancy census\n');
+ for (const finding of findings) console.error(` ${finding}\n`);
+ console.error(
+ ` The predicate is: resolveTenantFieldName(REGISTERED schema) !== null — the engine's own resolver,\n`
+ + ` on the schema AFTER ${PREDICATE_SOURCES.injectedColumns.split('/').pop()} has injected the tenant column.\n`
+ + ' ⛔ managedBy is not the predicate.\n',
+ );
+ return 1;
+}
+
+if (isEntrypoint(import.meta.url)) process.exit(main(process.argv.slice(2)));
diff --git a/scripts/platform-object-tenancy-census.json b/scripts/platform-object-tenancy-census.json
new file mode 100644
index 0000000000..39196be376
--- /dev/null
+++ b/scripts/platform-object-tenancy-census.json
@@ -0,0 +1,687 @@
+{
+ "$comment": [
+ "The platform-object tenancy census — DERIVED. Rebuild with `node scripts/platform-object-tenancy-census.mjs --write`;",
+ "`node scripts/check-platform-object-tenancy-census.mjs` holds it to the tree (#14957).",
+ "",
+ "⛔ Do not hand-edit a number here. This file exists because the same census WAS hand-written prose, in",
+ "packages/objectql/src/tenancy/platform-object-tenancy.ts, and failed twice: once with a wrong REASON behind a",
+ "right total (all excluded objects were `managedBy: better-auth`; one of them was described as an addition to",
+ "the set it was already in), and once with a stale TOTAL (PR #15155 declared `systemFields: { tenant: false }`",
+ "on sys_metadata_activation, moving it out of reach, and the ungated prose stayed at the old digits with CI green).",
+ "",
+ "THE PREDICATE IS THE POINT, the number is a by-product. `reach` is the answer of the engine's own",
+ "`resolveTenantFieldName` on the REGISTERED schema — the authored schema plus the columns",
+ "`resolveInjectedSystemColumns` injects. Both are loaded from source and executed by the generator.",
+ "⛔ `managedBy` is not the predicate; it is one declaration among several that make the predicate answer null.",
+ "",
+ "`reasons` are the declarations on the object's OWN schema, spelled as the schema spells them. They are NOT",
+ "mutually exclusive — an object carrying two is why flattening them into one \"the reason\" produced failure 1 —",
+ "and `reasonTotals` therefore counts objects per reason and does not sum to `totals.outOfReach`.",
+ "",
+ "AN UNKNOWN REASON IS AN ERROR. An out-of-reach object with no declared reason is `unexplained`: `--write`",
+ "refuses to commit it and the gate reds. A new exclusion mechanism gets adjudicated, never absorbed into a total."
+ ],
+ "predicate": "resolveTenantFieldName(registered schema) !== null, where the registered schema is the authored schema plus the columns resolveInjectedSystemColumns says the registration injects (the applySystemFields pass). Both functions are loaded from source and executed; neither is re-spelled here.",
+ "population": "every object registered by a tracked packages/**/*.object.ts module whose name carries a platform prefix (sys_ / cloud_ / ai_). The cloud repository's own cloud_ objects are not in this tree and so not in this census.",
+ "totals": {
+ "registered": 84,
+ "inReach": 58,
+ "outOfReach": 26
+ },
+ "reasonTotals": {
+ "managedBy: 'better-auth'": 25,
+ "systemFields.tenant: false": 1,
+ "tenancy.enabled: false": 2
+ },
+ "unexplained": [],
+ "objects": [
+ {
+ "name": "sys_account",
+ "file": "packages/platform-objects/src/identity/sys-account.object.ts",
+ "reach": "out",
+ "tenantField": null,
+ "reasons": [
+ "managedBy: 'better-auth'"
+ ]
+ },
+ {
+ "name": "sys_activity",
+ "file": "packages/plugins/plugin-audit/src/objects/sys-activity.object.ts",
+ "reach": "in",
+ "tenantField": "organization_id",
+ "reasons": []
+ },
+ {
+ "name": "sys_api_key",
+ "file": "packages/platform-objects/src/identity/sys-api-key.object.ts",
+ "reach": "out",
+ "tenantField": null,
+ "reasons": [
+ "managedBy: 'better-auth'",
+ "tenancy.enabled: false"
+ ]
+ },
+ {
+ "name": "sys_approval_action",
+ "file": "packages/plugins/plugin-approvals/src/sys-approval-action.object.ts",
+ "reach": "in",
+ "tenantField": "organization_id",
+ "reasons": []
+ },
+ {
+ "name": "sys_approval_approver",
+ "file": "packages/plugins/plugin-approvals/src/sys-approval-approver.object.ts",
+ "reach": "in",
+ "tenantField": "organization_id",
+ "reasons": []
+ },
+ {
+ "name": "sys_approval_delegation",
+ "file": "packages/plugins/plugin-approvals/src/sys-approval-delegation.object.ts",
+ "reach": "in",
+ "tenantField": "organization_id",
+ "reasons": []
+ },
+ {
+ "name": "sys_approval_request",
+ "file": "packages/plugins/plugin-approvals/src/sys-approval-request.object.ts",
+ "reach": "in",
+ "tenantField": "organization_id",
+ "reasons": []
+ },
+ {
+ "name": "sys_approval_token",
+ "file": "packages/plugins/plugin-approvals/src/sys-approval-token.object.ts",
+ "reach": "in",
+ "tenantField": "organization_id",
+ "reasons": []
+ },
+ {
+ "name": "sys_attachment",
+ "file": "packages/platform-objects/src/audit/sys-attachment.object.ts",
+ "reach": "in",
+ "tenantField": "organization_id",
+ "reasons": []
+ },
+ {
+ "name": "sys_audience_binding_suggestion",
+ "file": "packages/plugins/plugin-security/src/objects/sys-audience-binding-suggestion.object.ts",
+ "reach": "in",
+ "tenantField": "organization_id",
+ "reasons": []
+ },
+ {
+ "name": "sys_audit_log",
+ "file": "packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts",
+ "reach": "in",
+ "tenantField": "organization_id",
+ "reasons": []
+ },
+ {
+ "name": "sys_automation_run",
+ "file": "packages/services/service-automation/src/sys-automation-run.object.ts",
+ "reach": "in",
+ "tenantField": "organization_id",
+ "reasons": []
+ },
+ {
+ "name": "sys_business_unit",
+ "file": "packages/platform-objects/src/identity/sys-business-unit.object.ts",
+ "reach": "in",
+ "tenantField": "organization_id",
+ "reasons": []
+ },
+ {
+ "name": "sys_business_unit_member",
+ "file": "packages/platform-objects/src/identity/sys-business-unit-member.object.ts",
+ "reach": "in",
+ "tenantField": "organization_id",
+ "reasons": []
+ },
+ {
+ "name": "sys_capability",
+ "file": "packages/plugins/plugin-security/src/objects/sys-capability.object.ts",
+ "reach": "in",
+ "tenantField": "organization_id",
+ "reasons": []
+ },
+ {
+ "name": "sys_comment",
+ "file": "packages/plugins/plugin-audit/src/objects/sys-comment.object.ts",
+ "reach": "in",
+ "tenantField": "organization_id",
+ "reasons": []
+ },
+ {
+ "name": "sys_device_code",
+ "file": "packages/platform-objects/src/identity/sys-device-code.object.ts",
+ "reach": "out",
+ "tenantField": null,
+ "reasons": [
+ "managedBy: 'better-auth'"
+ ]
+ },
+ {
+ "name": "sys_email",
+ "file": "packages/platform-objects/src/audit/sys-email.object.ts",
+ "reach": "in",
+ "tenantField": "organization_id",
+ "reasons": []
+ },
+ {
+ "name": "sys_email_template",
+ "file": "packages/platform-objects/src/audit/sys-email-template.object.ts",
+ "reach": "in",
+ "tenantField": "organization_id",
+ "reasons": []
+ },
+ {
+ "name": "sys_file",
+ "file": "packages/services/service-storage/src/objects/system-file.object.ts",
+ "reach": "in",
+ "tenantField": "organization_id",
+ "reasons": []
+ },
+ {
+ "name": "sys_flow_dispatch",
+ "file": "packages/services/service-automation/src/sys-flow-dispatch.object.ts",
+ "reach": "in",
+ "tenantField": "organization_id",
+ "reasons": []
+ },
+ {
+ "name": "sys_http_delivery",
+ "file": "packages/services/service-messaging/src/objects/http-delivery.object.ts",
+ "reach": "in",
+ "tenantField": "organization_id",
+ "reasons": []
+ },
+ {
+ "name": "sys_import_job",
+ "file": "packages/platform-objects/src/audit/sys-import-job.object.ts",
+ "reach": "in",
+ "tenantField": "organization_id",
+ "reasons": []
+ },
+ {
+ "name": "sys_inbox_message",
+ "file": "packages/services/service-messaging/src/objects/inbox-message.object.ts",
+ "reach": "in",
+ "tenantField": "organization_id",
+ "reasons": []
+ },
+ {
+ "name": "sys_invitation",
+ "file": "packages/platform-objects/src/identity/sys-invitation.object.ts",
+ "reach": "in",
+ "tenantField": "organization_id",
+ "reasons": [
+ "managedBy: 'better-auth'"
+ ]
+ },
+ {
+ "name": "sys_job",
+ "file": "packages/platform-objects/src/audit/sys-job.object.ts",
+ "reach": "in",
+ "tenantField": "organization_id",
+ "reasons": []
+ },
+ {
+ "name": "sys_job_queue",
+ "file": "packages/platform-objects/src/audit/sys-job-queue.object.ts",
+ "reach": "in",
+ "tenantField": "organization_id",
+ "reasons": []
+ },
+ {
+ "name": "sys_job_run",
+ "file": "packages/platform-objects/src/audit/sys-job-run.object.ts",
+ "reach": "in",
+ "tenantField": "organization_id",
+ "reasons": []
+ },
+ {
+ "name": "sys_jwks",
+ "file": "packages/platform-objects/src/identity/sys-jwks.object.ts",
+ "reach": "out",
+ "tenantField": null,
+ "reasons": [
+ "managedBy: 'better-auth'"
+ ]
+ },
+ {
+ "name": "sys_member",
+ "file": "packages/platform-objects/src/identity/sys-member.object.ts",
+ "reach": "in",
+ "tenantField": "organization_id",
+ "reasons": [
+ "managedBy: 'better-auth'"
+ ]
+ },
+ {
+ "name": "sys_metadata",
+ "file": "packages/metadata-core/src/objects/sys-metadata.object.ts",
+ "reach": "in",
+ "tenantField": "organization_id",
+ "reasons": []
+ },
+ {
+ "name": "sys_metadata_activation",
+ "file": "packages/platform-objects/src/system/sys-metadata-activation.object.ts",
+ "reach": "out",
+ "tenantField": null,
+ "reasons": [
+ "systemFields.tenant: false"
+ ]
+ },
+ {
+ "name": "sys_metadata_audit",
+ "file": "packages/metadata-core/src/objects/sys-metadata-audit.object.ts",
+ "reach": "in",
+ "tenantField": "organization_id",
+ "reasons": []
+ },
+ {
+ "name": "sys_metadata_commit",
+ "file": "packages/metadata-core/src/objects/sys-metadata-commit.object.ts",
+ "reach": "in",
+ "tenantField": "organization_id",
+ "reasons": []
+ },
+ {
+ "name": "sys_metadata_history",
+ "file": "packages/metadata-core/src/objects/sys-metadata-history.object.ts",
+ "reach": "in",
+ "tenantField": "organization_id",
+ "reasons": []
+ },
+ {
+ "name": "sys_migration",
+ "file": "packages/platform-objects/src/system/sys-migration.object.ts",
+ "reach": "in",
+ "tenantField": "organization_id",
+ "reasons": []
+ },
+ {
+ "name": "sys_migration_journal",
+ "file": "packages/platform-objects/src/system/sys-migration-journal.object.ts",
+ "reach": "in",
+ "tenantField": "organization_id",
+ "reasons": []
+ },
+ {
+ "name": "sys_notification",
+ "file": "packages/platform-objects/src/audit/sys-notification.object.ts",
+ "reach": "in",
+ "tenantField": "organization_id",
+ "reasons": []
+ },
+ {
+ "name": "sys_notification_delivery",
+ "file": "packages/services/service-messaging/src/objects/notification-delivery.object.ts",
+ "reach": "in",
+ "tenantField": "organization_id",
+ "reasons": []
+ },
+ {
+ "name": "sys_notification_preference",
+ "file": "packages/services/service-messaging/src/objects/notification-preference.object.ts",
+ "reach": "in",
+ "tenantField": "organization_id",
+ "reasons": []
+ },
+ {
+ "name": "sys_notification_receipt",
+ "file": "packages/services/service-messaging/src/objects/notification-receipt.object.ts",
+ "reach": "in",
+ "tenantField": "organization_id",
+ "reasons": []
+ },
+ {
+ "name": "sys_notification_subscription",
+ "file": "packages/services/service-messaging/src/objects/notification-subscription.object.ts",
+ "reach": "in",
+ "tenantField": "organization_id",
+ "reasons": []
+ },
+ {
+ "name": "sys_notification_template",
+ "file": "packages/services/service-messaging/src/objects/notification-template.object.ts",
+ "reach": "in",
+ "tenantField": "organization_id",
+ "reasons": []
+ },
+ {
+ "name": "sys_oauth_access_token",
+ "file": "packages/platform-objects/src/identity/sys-oauth-access-token.object.ts",
+ "reach": "out",
+ "tenantField": null,
+ "reasons": [
+ "managedBy: 'better-auth'"
+ ]
+ },
+ {
+ "name": "sys_oauth_application",
+ "file": "packages/platform-objects/src/identity/sys-oauth-application.object.ts",
+ "reach": "out",
+ "tenantField": null,
+ "reasons": [
+ "managedBy: 'better-auth'"
+ ]
+ },
+ {
+ "name": "sys_oauth_client_assertion",
+ "file": "packages/platform-objects/src/identity/sys-oauth-client-assertion.object.ts",
+ "reach": "out",
+ "tenantField": null,
+ "reasons": [
+ "managedBy: 'better-auth'"
+ ]
+ },
+ {
+ "name": "sys_oauth_client_resource",
+ "file": "packages/platform-objects/src/identity/sys-oauth-client-resource.object.ts",
+ "reach": "out",
+ "tenantField": null,
+ "reasons": [
+ "managedBy: 'better-auth'"
+ ]
+ },
+ {
+ "name": "sys_oauth_consent",
+ "file": "packages/platform-objects/src/identity/sys-oauth-consent.object.ts",
+ "reach": "out",
+ "tenantField": null,
+ "reasons": [
+ "managedBy: 'better-auth'"
+ ]
+ },
+ {
+ "name": "sys_oauth_refresh_token",
+ "file": "packages/platform-objects/src/identity/sys-oauth-refresh-token.object.ts",
+ "reach": "out",
+ "tenantField": null,
+ "reasons": [
+ "managedBy: 'better-auth'"
+ ]
+ },
+ {
+ "name": "sys_oauth_resource",
+ "file": "packages/platform-objects/src/identity/sys-oauth-resource.object.ts",
+ "reach": "out",
+ "tenantField": null,
+ "reasons": [
+ "managedBy: 'better-auth'"
+ ]
+ },
+ {
+ "name": "sys_organization",
+ "file": "packages/platform-objects/src/identity/sys-organization.object.ts",
+ "reach": "out",
+ "tenantField": null,
+ "reasons": [
+ "managedBy: 'better-auth'"
+ ]
+ },
+ {
+ "name": "sys_permission_set",
+ "file": "packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts",
+ "reach": "in",
+ "tenantField": "organization_id",
+ "reasons": []
+ },
+ {
+ "name": "sys_position",
+ "file": "packages/plugins/plugin-security/src/objects/sys-position.object.ts",
+ "reach": "in",
+ "tenantField": "organization_id",
+ "reasons": []
+ },
+ {
+ "name": "sys_position_permission_set",
+ "file": "packages/plugins/plugin-security/src/objects/sys-position-permission-set.object.ts",
+ "reach": "in",
+ "tenantField": "organization_id",
+ "reasons": []
+ },
+ {
+ "name": "sys_presence",
+ "file": "packages/services/service-realtime/src/objects/sys-presence.object.ts",
+ "reach": "in",
+ "tenantField": "organization_id",
+ "reasons": []
+ },
+ {
+ "name": "sys_record_share",
+ "file": "packages/plugins/plugin-sharing/src/objects/sys-record-share.object.ts",
+ "reach": "in",
+ "tenantField": "organization_id",
+ "reasons": []
+ },
+ {
+ "name": "sys_report_schedule",
+ "file": "packages/platform-objects/src/audit/sys-report-schedule.object.ts",
+ "reach": "in",
+ "tenantField": "organization_id",
+ "reasons": []
+ },
+ {
+ "name": "sys_saved_report",
+ "file": "packages/platform-objects/src/audit/sys-saved-report.object.ts",
+ "reach": "in",
+ "tenantField": "organization_id",
+ "reasons": []
+ },
+ {
+ "name": "sys_scim_connection_binding",
+ "file": "packages/platform-objects/src/identity/sys-scim-connection-binding.object.ts",
+ "reach": "out",
+ "tenantField": null,
+ "reasons": [
+ "managedBy: 'better-auth'"
+ ]
+ },
+ {
+ "name": "sys_scim_connection_credential",
+ "file": "packages/platform-objects/src/identity/sys-scim-connection-credential.object.ts",
+ "reach": "in",
+ "tenantField": "organization_id",
+ "reasons": []
+ },
+ {
+ "name": "sys_scim_group",
+ "file": "packages/platform-objects/src/identity/sys-scim-group.object.ts",
+ "reach": "out",
+ "tenantField": null,
+ "reasons": [
+ "managedBy: 'better-auth'"
+ ]
+ },
+ {
+ "name": "sys_scim_group_member",
+ "file": "packages/platform-objects/src/identity/sys-scim-group-member.object.ts",
+ "reach": "out",
+ "tenantField": null,
+ "reasons": [
+ "managedBy: 'better-auth'"
+ ]
+ },
+ {
+ "name": "sys_scim_identity_tombstone",
+ "file": "packages/platform-objects/src/identity/sys-scim-identity-tombstone.object.ts",
+ "reach": "out",
+ "tenantField": null,
+ "reasons": [
+ "managedBy: 'better-auth'"
+ ]
+ },
+ {
+ "name": "sys_scim_projection_grant",
+ "file": "packages/platform-objects/src/identity/sys-scim-projection-grant.object.ts",
+ "reach": "out",
+ "tenantField": null,
+ "reasons": [
+ "managedBy: 'better-auth'"
+ ]
+ },
+ {
+ "name": "sys_scim_subject",
+ "file": "packages/platform-objects/src/identity/sys-scim-subject.object.ts",
+ "reach": "out",
+ "tenantField": null,
+ "reasons": [
+ "managedBy: 'better-auth'"
+ ]
+ },
+ {
+ "name": "sys_scim_user",
+ "file": "packages/platform-objects/src/identity/sys-scim-user.object.ts",
+ "reach": "out",
+ "tenantField": null,
+ "reasons": [
+ "managedBy: 'better-auth'"
+ ]
+ },
+ {
+ "name": "sys_secret",
+ "file": "packages/platform-objects/src/system/sys-secret.object.ts",
+ "reach": "in",
+ "tenantField": "organization_id",
+ "reasons": []
+ },
+ {
+ "name": "sys_session",
+ "file": "packages/platform-objects/src/identity/sys-session.object.ts",
+ "reach": "out",
+ "tenantField": null,
+ "reasons": [
+ "managedBy: 'better-auth'"
+ ]
+ },
+ {
+ "name": "sys_setting",
+ "file": "packages/platform-objects/src/system/sys-setting.object.ts",
+ "reach": "in",
+ "tenantField": "organization_id",
+ "reasons": []
+ },
+ {
+ "name": "sys_setting_audit",
+ "file": "packages/platform-objects/src/system/sys-setting-audit.object.ts",
+ "reach": "in",
+ "tenantField": "organization_id",
+ "reasons": []
+ },
+ {
+ "name": "sys_share_link",
+ "file": "packages/plugins/plugin-sharing/src/objects/sys-share-link.object.ts",
+ "reach": "in",
+ "tenantField": "organization_id",
+ "reasons": []
+ },
+ {
+ "name": "sys_sharing_rule",
+ "file": "packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts",
+ "reach": "in",
+ "tenantField": "organization_id",
+ "reasons": []
+ },
+ {
+ "name": "sys_sso_provider",
+ "file": "packages/platform-objects/src/identity/sys-sso-provider.object.ts",
+ "reach": "out",
+ "tenantField": null,
+ "reasons": [
+ "managedBy: 'better-auth'",
+ "tenancy.enabled: false"
+ ]
+ },
+ {
+ "name": "sys_team",
+ "file": "packages/platform-objects/src/identity/sys-team.object.ts",
+ "reach": "in",
+ "tenantField": "organization_id",
+ "reasons": [
+ "managedBy: 'better-auth'"
+ ]
+ },
+ {
+ "name": "sys_team_member",
+ "file": "packages/platform-objects/src/identity/sys-team-member.object.ts",
+ "reach": "out",
+ "tenantField": null,
+ "reasons": [
+ "managedBy: 'better-auth'"
+ ]
+ },
+ {
+ "name": "sys_two_factor",
+ "file": "packages/platform-objects/src/identity/sys-two-factor.object.ts",
+ "reach": "out",
+ "tenantField": null,
+ "reasons": [
+ "managedBy: 'better-auth'"
+ ]
+ },
+ {
+ "name": "sys_upload_session",
+ "file": "packages/services/service-storage/src/objects/system-upload-session.object.ts",
+ "reach": "in",
+ "tenantField": "organization_id",
+ "reasons": []
+ },
+ {
+ "name": "sys_user",
+ "file": "packages/platform-objects/src/identity/sys-user.object.ts",
+ "reach": "out",
+ "tenantField": null,
+ "reasons": [
+ "managedBy: 'better-auth'"
+ ]
+ },
+ {
+ "name": "sys_user_permission_set",
+ "file": "packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts",
+ "reach": "in",
+ "tenantField": "organization_id",
+ "reasons": []
+ },
+ {
+ "name": "sys_user_position",
+ "file": "packages/plugins/plugin-security/src/objects/sys-user-position.object.ts",
+ "reach": "in",
+ "tenantField": "organization_id",
+ "reasons": []
+ },
+ {
+ "name": "sys_user_preference",
+ "file": "packages/platform-objects/src/identity/sys-user-preference.object.ts",
+ "reach": "in",
+ "tenantField": "organization_id",
+ "reasons": []
+ },
+ {
+ "name": "sys_verification",
+ "file": "packages/platform-objects/src/identity/sys-verification.object.ts",
+ "reach": "out",
+ "tenantField": null,
+ "reasons": [
+ "managedBy: 'better-auth'"
+ ]
+ },
+ {
+ "name": "sys_view_definition",
+ "file": "packages/metadata-core/src/objects/sys-view-definition.object.ts",
+ "reach": "in",
+ "tenantField": "organization_id",
+ "reasons": []
+ },
+ {
+ "name": "sys_webhook",
+ "file": "packages/plugins/plugin-webhooks/src/sys-webhook.object.ts",
+ "reach": "in",
+ "tenantField": "organization_id",
+ "reasons": []
+ }
+ ]
+}
diff --git a/scripts/platform-object-tenancy-census.mjs b/scripts/platform-object-tenancy-census.mjs
new file mode 100644
index 0000000000..0f55b84c70
--- /dev/null
+++ b/scripts/platform-object-tenancy-census.mjs
@@ -0,0 +1,616 @@
+#!/usr/bin/env node
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * platform-object-tenancy-census -- the re-derivable enumeration of which
+ * platform-namespace objects the tenancy machinery can reach, and why each
+ * excluded one is excluded (#14957).
+ *
+ * node scripts/platform-object-tenancy-census.mjs # human summary
+ * node scripts/platform-object-tenancy-census.mjs --json # the whole census
+ * node scripts/platform-object-tenancy-census.mjs --write # rewrite the artefact
+ *
+ * `scripts/platform-object-tenancy-census.json` is the artefact this builds.
+ * `check-platform-object-tenancy-census.mjs` is the gate that holds it to the
+ * tree. Together they are the `tenant-audit-census.mjs` triple's shape applied
+ * to the per-object tenancy classification.
+ *
+ * ⚠️ This module deliberately exposes NO `--self-test` flag of its own, for the
+ * reason `tenant-audit-census.mjs` records for itself: a flag would make CI
+ * invoke this file directly, which makes it a GATE FILE, and
+ * `scripts/pm/dispatch-gates.mjs` refuses to follow a gate file -- the path
+ * literals this module spells would stop being inherited by the gate that
+ * imports it. {@link selfTest} below is real and runs on every CI pass:
+ * `check-platform-object-tenancy-census.mjs --self-test` calls it.
+ *
+ * ## ⭐ THE PREDICATE, which is the deliverable; the number is a by-product
+ *
+ * "In the machinery's reach" is ONE question with ONE answer:
+ *
+ * resolveTenantFieldName(REGISTERED schema) !== null
+ *
+ * REGISTERED, not authored. `applySystemFields` (`objectql/src/registry.ts`)
+ * provisions the tenant COLUMN before the engine ever sees the object, so the
+ * schema the resolver reads is not the one the author typed. Reading the
+ * authored `fields` alone answers a different question and answers it
+ * confidently.
+ *
+ * ⛔ `managedBy` is NOT the predicate. It is one of several DECLARATIONS that
+ * happen to make the predicate answer null, and counting it as if the resolver
+ * read it is precisely the mistake this artefact exists to make impossible --
+ * see the drift transcript below, where that reading produced a right total
+ * from a wrong reason and then a stale total from the same prose.
+ *
+ * ## ⭐ The predicate is EXECUTED, never transcribed
+ *
+ * Both halves are loaded FROM SOURCE and called (see {@link loadPredicate}):
+ *
+ * - `resolveInjectedSystemColumns` (`packages/spec/src/data/
+ * injected-system-columns.ts`) -- WHICH columns the registration injects.
+ * `registry.ts` documents it as "the single source consumed by
+ * `applySystemFields`", and it is deliberately runtime-free so author-time
+ * consumers can call it.
+ * - `resolveTenantFieldName` (`packages/objectql/src/tenancy/
+ * system-write-organization.ts`) -- the engine's own resolver, the exact
+ * function the header's prose names.
+ *
+ * A transcription of either rule here would be a SECOND copy of a rule the
+ * engine already owns, free to drift from it while reading as authoritative --
+ * which is the failure one layer up from the one this file fixes. Loading them
+ * costs a TypeScript transpile of their import graph and needs no build, which
+ * is why the gate can live in the `lint` job (that job installs; it does not
+ * build).
+ *
+ * ⛔ The loader NEVER stubs a module it cannot resolve. A stub would let the
+ * census answer confidently out of code that is not the engine's -- the same
+ * class of confident-wrong-answer the whole artefact is aimed at. It throws.
+ *
+ * ## ⭐ Why this exists AS AN ARTEFACT: the drift, measured
+ *
+ * `packages/objectql/src/tenancy/platform-object-tenancy.ts` used to state the
+ * census in PROSE: "84 ... 25 ... (24 `managedBy: 'better-auth'`, plus
+ * `sys_sso_provider`'s `tenancy.enabled: false`) ... 59". Nothing held it to
+ * the tree, and it failed in both available directions inside one month:
+ *
+ * 1. WRONG REASON, RIGHT TOTAL. All 25 excluded objects were `managedBy:
+ * 'better-auth'`; `sys_sso_provider` was one OF them, not an addition on
+ * top, and `sys_api_key` carries `tenancy.enabled: false` too and went
+ * unnamed. `24 + 1 = 25` is right, which is why no reader caught it.
+ * 2. RIGHT REASON, STALE TOTAL. On 2026-09-04 04:37:18Z, commit efb3513178
+ * (PR #15155, from #15024) declared `systemFields: { tenant: false }` on
+ * `sys_metadata_activation` and dropped its reserved `organization_id`.
+ * That object left the machinery's reach: 84 / 25 / 59 became 84 / 26 /
+ * 58. The same commit updated the GATED page next door
+ * (`content/docs/permissions/system-context.mdx`) and did not touch the
+ * ungated header, and CI was green throughout. The digits were stale for
+ * the whole of the following day with nothing anywhere saying so.
+ *
+ * ⇒ Failure 2 also introduced a THIRD exclusion mechanism that the prose's
+ * taxonomy had no slot for. That is why {@link EXCLUSION_REASONS} is an
+ * announce-never-absorb list rather than a summary: see below.
+ *
+ * ## ⭐ Reasons are recorded VERBATIM, and an unknown one is an ERROR
+ *
+ * Each excluded object carries the declarations from ITS OWN schema that
+ * produce the exclusion, spelled the way the schema spells them
+ * (`managedBy: 'better-auth'`, `systemFields.tenant: false`, ...). They are
+ * NOT mutually exclusive and are not meant to be: `sys_api_key` carries two,
+ * and flattening that into one "the reason" is how `sys_sso_provider` came to
+ * be described as an addition to a set it was already in.
+ *
+ * An object the predicate excludes for which NO declared reason is found is
+ * `unexplained`. That is an ERROR, never a default: `--write` REFUSES to
+ * commit such a row and the gate reds on it. A new exclusion mechanism is
+ * announced and adjudicated, never silently absorbed into a total -- the same
+ * shape `tenant-audit-census.mjs` uses for an unplaceable receiver.
+ */
+
+import { readFileSync, writeFileSync, existsSync, statSync } from 'node:fs';
+import { execFileSync } from 'node:child_process';
+import { createRequire } from 'node:module';
+import { dirname, join, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+import { isEntrypoint } from './invoked-as.mjs';
+import { transpileChecked } from './ts-parse.mjs';
+
+const ROOT = join(fileURLToPath(new URL('.', import.meta.url)), '..');
+
+/** The committed artefact this builds and the gate compares against. */
+export const ARTEFACT = 'scripts/platform-object-tenancy-census.json';
+
+/**
+ * The header whose hand-written digits this artefact replaced. The gate
+ * requires it to keep POINTING here -- see the gate's `HEADER_MARKERS`.
+ */
+export const HEADER = 'packages/objectql/src/tenancy/platform-object-tenancy.ts';
+
+/** The two files the predicate is loaded from. Both are executed, not read. */
+export const PREDICATE_SOURCES = {
+ injectedColumns: 'packages/spec/src/data/injected-system-columns.ts',
+ tenantFieldResolver: 'packages/objectql/src/tenancy/system-write-organization.ts',
+};
+
+/**
+ * The namespace test, spelled as `system-write-organization.ts` spells it.
+ *
+ * ⚠️ NAME SHAPE ONLY. It selects the POPULATION this census is about; it says
+ * nothing about tenancy, and `isPlatformNamespaceObject`'s own docblock forbids
+ * reaching for it to decide tenancy. The five `cloud_` runtime objects defined
+ * in the separate `cloud` repository are out of this tree's reach and so out of
+ * this census -- "registered in THIS repository" is the population's scope.
+ */
+const PLATFORM_NAMESPACE = /^(sys_|cloud_|ai_)/;
+
+/**
+ * The exclusion mechanisms this census knows, each a DECLARATION read straight
+ * off the object's own schema, keyed by the spelling the schema uses.
+ *
+ * ⛔ Do NOT add a row to shorten an `unexplained` list. A row here is a claim
+ * that the tree has a mechanism, and the adjudication that admits one is what
+ * failure 2 above skipped.
+ */
+export const EXCLUSION_REASONS = [
+ {
+ id: "managedBy: 'better-auth'",
+ // better-auth owns these tables' column layout, so the registration injects
+ // nothing at all -- `resolveInjectedSystemColumns` returns its `nothing`
+ // plan. The column never exists, so the resolver never finds one.
+ detect: (def) => def.managedBy === 'better-auth',
+ },
+ {
+ id: 'systemFields: false',
+ // The hard object-level opt-out (seed / migration tables): nothing injected.
+ detect: (def) => def.systemFields === false,
+ },
+ {
+ id: 'systemFields.tenant: false',
+ // The narrow opt-out: audit columns still land, the tenant column does not.
+ // This is the mechanism #15155 introduced to this population.
+ detect: (def) =>
+ def.systemFields !== null
+ && typeof def.systemFields === 'object'
+ && def.systemFields.tenant === false,
+ },
+ {
+ id: 'tenancy.enabled: false',
+ // ADR-0066's declaration that the table is a shared/global catalog. It
+ // suppresses the column AND is read FIRST by `resolveTenantFieldName`, so
+ // it excludes even an object that carries the column anyway.
+ detect: (def) => def.tenancy !== null && typeof def.tenancy === 'object' && def.tenancy.enabled === false,
+ },
+];
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Loading the predicate from source
+// ─────────────────────────────────────────────────────────────────────────────
+
+/**
+ * Load a TypeScript module by transpiling it and its import graph.
+ *
+ * Three resolution rules, in order, and a throw when none of them answers:
+ * - a relative specifier resolves inside the tree, with `.js` rewritten to
+ * `.ts` (the tree's ESM-style spelling of a TypeScript import);
+ * - `@objectstack/[/sub]` resolves to that package's SOURCE, because the
+ * `exports` map points at a `dist/` this gate must not require a build for;
+ * - anything else is a real installed dependency and goes to `require`,
+ * resolved from the IMPORTING file so a package's own dependencies are on
+ * its own resolution path.
+ *
+ * ⛔ There is no fourth rule and no fallback value. A specifier that resolves
+ * to nothing throws.
+ */
+export function createSourceLoader(root = ROOT) {
+ const cache = new Map();
+ const loadedFiles = [];
+
+ const resolveTs = (spec, fromDir) => {
+ const base = resolve(fromDir, spec.replace(/\.(js|jsx|mjs)$/, ''));
+ for (const candidate of [`${base}.ts`, `${base}.tsx`, join(base, 'index.ts'), base]) {
+ try {
+ if (existsSync(candidate) && statSync(candidate).isFile()) return candidate;
+ } catch { /* fall through to the next candidate */ }
+ }
+ return null;
+ };
+
+ const load = (file) => {
+ const hit = cache.get(file);
+ if (hit) return hit.exports;
+ const source = readFileSync(file, 'utf8');
+ // `transpileChecked`, never a raw `ts.transpileModule`: the raw call reports
+ // NOTHING on a source it could not read and still returns an `outputText`,
+ // so an unparseable module would be evaluated as whatever survived and the
+ // census would score the tree against wreckage with a clean exit. That is
+ // this artefact's own failure class one layer down (`check-parse-guard`).
+ const js = transpileChecked(file, source, {
+ compilerOptions: { module: 'commonjs', target: 'es2022' },
+ }).outputText;
+ const mod = { exports: {} };
+ // Seeded BEFORE evaluation so an import cycle sees the partial module the
+ // way CommonJS does, instead of recursing until the stack ends.
+ cache.set(file, mod);
+ loadedFiles.push(file.startsWith(root) ? file.slice(root.length) : file);
+ const req = (spec) => {
+ if (spec.startsWith('.')) {
+ const target = resolveTs(spec, dirname(file));
+ if (!target) {
+ throw new Error(
+ `platform-object-tenancy-census: unresolved relative import '${spec}' from ${file} -- `
+ + 'refusing to continue with a module graph this loader cannot read in full.',
+ );
+ }
+ return load(target);
+ }
+ const workspace = /^@objectstack\/([a-z0-9-]+)(?:\/(.+))?$/.exec(spec);
+ if (workspace) {
+ const target = resolveTs(join(root, 'packages', workspace[1], 'src', workspace[2] ?? ''), '/');
+ if (target) return load(target);
+ }
+ return createRequire(file)(spec);
+ };
+ // eslint-disable-next-line no-new-func -- the whole point: evaluate the
+ // tree's own transpiled source rather than re-spelling what it decides.
+ new Function('require', 'module', 'exports', '__filename', '__dirname', js)(
+ req, mod, mod.exports, file, dirname(file),
+ );
+ return mod.exports;
+ };
+
+ return { load, loadedFiles, moduleCount: () => cache.size };
+}
+
+/**
+ * The predicate's two halves, loaded and PROVEN to behave before use.
+ *
+ * The proof is not decoration. A loader that silently returned an empty object,
+ * or a future refactor that moved the export, would otherwise leave every
+ * object scored the same way with a clean exit -- the silent-success direction
+ * this tree treats as worse than no check. So four fixed inputs with answers
+ * that cannot both change and stay sensible are asserted here, at load time,
+ * on every run including production.
+ */
+export function loadPredicate(root = ROOT) {
+ const loader = createSourceLoader(root);
+ const spec = loader.load(join(root, PREDICATE_SOURCES.injectedColumns));
+ const engine = loader.load(join(root, PREDICATE_SOURCES.tenantFieldResolver));
+
+ const resolveInjectedSystemColumns = spec.resolveInjectedSystemColumns;
+ const resolveTenantFieldName = engine.resolveTenantFieldName;
+ for (const [name, fn, file] of [
+ ['resolveInjectedSystemColumns', resolveInjectedSystemColumns, PREDICATE_SOURCES.injectedColumns],
+ ['resolveTenantFieldName', resolveTenantFieldName, PREDICATE_SOURCES.tenantFieldResolver],
+ ]) {
+ if (typeof fn !== 'function') {
+ throw new Error(
+ `platform-object-tenancy-census: ${file} did not export a callable '${name}'. The predicate `
+ + 'is EXECUTED, so a missing export is a hard stop -- there is no transcription to fall back on.',
+ );
+ }
+ }
+
+ const proofs = [
+ ['a plain object gets the tenant column',
+ resolveInjectedSystemColumns({ name: 'sys_x' }).tenant === true],
+ ["a better-auth table gets nothing",
+ resolveInjectedSystemColumns({ name: 'sys_x', managedBy: 'better-auth' }).tenant === false],
+ ['the resolver finds the injected column',
+ resolveTenantFieldName({ fields: { organization_id: {} } }) === 'organization_id'],
+ ['the resolver honours the opt-out over a present column',
+ resolveTenantFieldName({ fields: { organization_id: {} }, tenancy: { enabled: false } }) === null],
+ ];
+ const failed = proofs.filter(([, ok]) => !ok).map(([what]) => what);
+ if (failed.length > 0) {
+ throw new Error(
+ 'platform-object-tenancy-census: the functions loaded from source do not behave like the '
+ + `predicate they are supposed to BE (${failed.join('; ')}). Refusing to census against them.`,
+ );
+ }
+
+ return { resolveInjectedSystemColumns, resolveTenantFieldName, loader };
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// The population
+// ─────────────────────────────────────────────────────────────────────────────
+
+/**
+ * Every object this repository REGISTERS, loaded from its own declaration file.
+ *
+ * The population is the tracked `*.object.ts` modules under `packages/`, which
+ * is where an object's registration is declared. They are loaded rather than
+ * parsed: an AST reading would have to re-derive what a spread, a shared field
+ * bundle or a helper builder contributes to `fields`, and the predicate's whole
+ * point is that it runs on the real schema.
+ */
+export function registeredObjects(root = ROOT, loader = createSourceLoader(root)) {
+ const files = execFileSync('git', ['-C', root, 'ls-files', 'packages'], { encoding: 'utf8', maxBuffer: 1 << 28 })
+ .split('\n')
+ .filter((f) => /\.object\.tsx?$/.test(f) && !f.includes('/dist/'))
+ .sort();
+ if (files.length === 0) {
+ throw new Error(
+ 'platform-object-tenancy-census: the object walk found ZERO declaration files -- a walk that '
+ + 'found nothing and a tree with nothing in it are different, and this refuses to guess which.',
+ );
+ }
+ const objects = [];
+ for (const rel of files) {
+ const mod = loader.load(join(root, rel));
+ for (const value of Object.values(mod)) {
+ if (value && typeof value === 'object' && typeof value.name === 'string' && value.fields) {
+ objects.push({ name: value.name, file: rel, def: value });
+ }
+ }
+ }
+ if (objects.length === 0) {
+ throw new Error(
+ `platform-object-tenancy-census: loaded ${files.length} declaration files and found no object `
+ + 'schema in any of them. Refusing to census against nothing.',
+ );
+ }
+ return objects;
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// The census
+// ─────────────────────────────────────────────────────────────────────────────
+
+/**
+ * Run the predicate over the platform namespaces.
+ *
+ * The registered schema is reconstructed the way `applySystemFields` builds it:
+ * the authored fields plus the columns the injection plan says exist. The plan
+ * is the spec's own (`resolveInjectedSystemColumns`), so nothing here decides
+ * WHICH columns are injected; this only unions them in.
+ */
+export function runCensus(root = ROOT) {
+ const { resolveInjectedSystemColumns, resolveTenantFieldName, loader } = loadPredicate(root);
+ const objects = registeredObjects(root, loader);
+
+ const rows = [];
+ for (const { name, file, def } of objects) {
+ if (!PLATFORM_NAMESPACE.test(name)) continue;
+ const plan = resolveInjectedSystemColumns(def);
+ const registeredFields = { ...def.fields };
+ for (const column of plan.names) {
+ if (!Object.prototype.hasOwnProperty.call(registeredFields, column)) registeredFields[column] = {};
+ }
+ const tenantField = resolveTenantFieldName({ ...def, fields: registeredFields });
+ const reasons = EXCLUSION_REASONS.filter((r) => r.detect(def)).map((r) => r.id);
+ rows.push({
+ name,
+ file,
+ reach: tenantField === null ? 'out' : 'in',
+ tenantField,
+ // Recorded on IN-reach rows too, and deliberately: an object that
+ // declares an exclusion mechanism and is still in reach is a fact worth
+ // seeing, not a contradiction to hide.
+ reasons,
+ });
+ }
+ rows.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
+ if (rows.length === 0) {
+ throw new Error(
+ `platform-object-tenancy-census: ${objects.length} objects registered and NONE in a platform `
+ + 'namespace. That is a population failure, not a census result.',
+ );
+ }
+
+ const outOfReach = rows.filter((r) => r.reach === 'out');
+ const reasonTotals = {};
+ for (const reason of EXCLUSION_REASONS) {
+ const n = outOfReach.filter((r) => r.reasons.includes(reason.id)).length;
+ if (n > 0) reasonTotals[reason.id] = n;
+ }
+
+ return {
+ predicate: 'resolveTenantFieldName(registered schema) !== null, where the registered schema is the '
+ + 'authored schema plus the columns resolveInjectedSystemColumns says the registration injects '
+ + '(the applySystemFields pass). Both functions are loaded from source and executed; neither is '
+ + 're-spelled here.',
+ population: 'every object registered by a tracked packages/**/*.object.ts module whose name carries '
+ + 'a platform prefix (sys_ / cloud_ / ai_). The cloud repository\'s own cloud_ objects are not in '
+ + 'this tree and so not in this census.',
+ totals: { registered: rows.length, inReach: rows.length - outOfReach.length, outOfReach: outOfReach.length },
+ reasonTotals,
+ unexplained: outOfReach.filter((r) => r.reasons.length === 0).map((r) => r.name),
+ objects: rows,
+ };
+}
+
+/** Objects the predicate excludes with no declared mechanism to explain it. */
+export function unexplainedExclusions(census) {
+ return census.objects.filter((r) => r.reach === 'out' && r.reasons.length === 0).map((r) => r.name);
+}
+
+const ARTEFACT_COMMENT = [
+ 'The platform-object tenancy census — DERIVED. Rebuild with `node scripts/platform-object-tenancy-census.mjs --write`;',
+ '`node scripts/check-platform-object-tenancy-census.mjs` holds it to the tree (#14957).',
+ '',
+ '⛔ Do not hand-edit a number here. This file exists because the same census WAS hand-written prose, in',
+ 'packages/objectql/src/tenancy/platform-object-tenancy.ts, and failed twice: once with a wrong REASON behind a',
+ 'right total (all excluded objects were `managedBy: better-auth`; one of them was described as an addition to',
+ 'the set it was already in), and once with a stale TOTAL (PR #15155 declared `systemFields: { tenant: false }`',
+ 'on sys_metadata_activation, moving it out of reach, and the ungated prose stayed at the old digits with CI green).',
+ '',
+ 'THE PREDICATE IS THE POINT, the number is a by-product. `reach` is the answer of the engine\'s own',
+ '`resolveTenantFieldName` on the REGISTERED schema — the authored schema plus the columns',
+ '`resolveInjectedSystemColumns` injects. Both are loaded from source and executed by the generator.',
+ '⛔ `managedBy` is not the predicate; it is one declaration among several that make the predicate answer null.',
+ '',
+ '`reasons` are the declarations on the object\'s OWN schema, spelled as the schema spells them. They are NOT',
+ 'mutually exclusive — an object carrying two is why flattening them into one "the reason" produced failure 1 —',
+ 'and `reasonTotals` therefore counts objects per reason and does not sum to `totals.outOfReach`.',
+ '',
+ 'AN UNKNOWN REASON IS AN ERROR. An out-of-reach object with no declared reason is `unexplained`: `--write`',
+ 'refuses to commit it and the gate reds. A new exclusion mechanism gets adjudicated, never absorbed into a total.',
+];
+
+export function renderArtefact(census) {
+ return `${JSON.stringify({ $comment: ARTEFACT_COMMENT, ...census }, null, 2)}\n`;
+}
+
+export function readArtefact(root = ROOT) {
+ return JSON.parse(readFileSync(join(root, ARTEFACT), 'utf8'));
+}
+
+export function writeArtefact(root = ROOT, census = runCensus(root)) {
+ const unexplained = unexplainedExclusions(census);
+ if (unexplained.length > 0) {
+ throw new Error(
+ `platform-object-tenancy-census: REFUSING to write — ${unexplained.length} object(s) are outside the `
+ + `machinery's reach with no declared mechanism to explain it: ${unexplained.join(', ')}. A new exclusion `
+ + 'mechanism is adjudicated and added to EXCLUSION_REASONS with its reasoning, never absorbed into a total.',
+ );
+ }
+ writeFileSync(join(root, ARTEFACT), renderArtefact(census));
+ return census;
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// selfTest -- run by the GATE's `--self-test`, never by a flag of this file's
+// ─────────────────────────────────────────────────────────────────────────────
+
+/**
+ * The generator's own battery.
+ *
+ * Its classifiers are PUBLISHED FIGURES rather than findings: the gate's verdict
+ * is "the artefact equals the tree", and an equality holds just as well between
+ * two identically-wrong sides. So the reason detectors and the registered-schema
+ * reconstruction have no other instrument, and a clean tree cannot tell a
+ * working classifier from a weakened one.
+ */
+export function selfTest(root = ROOT) {
+ let failures = 0;
+ const t = (what, got, want = true) => {
+ const ok = JSON.stringify(got) === JSON.stringify(want);
+ if (!ok) failures++;
+ console.log(` ${ok ? '✓' : '✗'} ${what}${ok ? '' : `\n got ${JSON.stringify(got)} want ${JSON.stringify(want)}`}`);
+ };
+
+ console.log('platform-object-tenancy-census generator');
+
+ // The predicate is loaded, not transcribed -- so the first thing to prove is
+ // that the loading really happened against the tree's files.
+ const { resolveInjectedSystemColumns, resolveTenantFieldName } = loadPredicate(root);
+ t('the predicate loads from source and is callable', [typeof resolveInjectedSystemColumns, typeof resolveTenantFieldName], ['function', 'function']);
+ t('a bare object resolves the injected tenant column',
+ resolveTenantFieldName({ fields: { organization_id: {} } }), 'organization_id');
+ t('a declared tenantField that the object really has wins',
+ resolveTenantFieldName({ fields: { org: {} }, tenancy: { tenantField: 'org' } }), 'org');
+ t('a declared tenantField the object does NOT have is not invented',
+ resolveTenantFieldName({ fields: { organization_id: {} }, tenancy: { tenantField: 'missing' } }), 'organization_id');
+ t('no tenant column, no answer', resolveTenantFieldName({ fields: { id: {} } }), null);
+
+ // ⭐ The registered/authored distinction, which is the failure the prose made.
+ // An ordinary platform object declares NO organization_id and is nonetheless
+ // in reach, because the registration injects it. A census reading authored
+ // fields alone scores this one 'out' and is confidently wrong.
+ const authored = { name: 'sys_probe', fields: { id: {} } };
+ t('the authored schema alone answers null (the wrong reading)',
+ resolveTenantFieldName(authored), null);
+ const plan = resolveInjectedSystemColumns(authored);
+ const registered = { ...authored, fields: { ...authored.fields } };
+ for (const c of plan.names) registered.fields[c] ??= {};
+ t('…and the REGISTERED schema answers the tenant column (the predicate)',
+ resolveTenantFieldName(registered), 'organization_id');
+
+ // Each reason detector, on a schema carrying exactly that declaration.
+ const detect = (def) => EXCLUSION_REASONS.filter((r) => r.detect(def)).map((r) => r.id);
+ t("managedBy: 'better-auth' is detected", detect({ managedBy: 'better-auth' }), ["managedBy: 'better-auth'"]);
+ t('systemFields: false is detected', detect({ systemFields: false }), ['systemFields: false']);
+ t('systemFields.tenant: false is detected', detect({ systemFields: { tenant: false } }), ['systemFields.tenant: false']);
+ t('tenancy.enabled: false is detected', detect({ tenancy: { enabled: false } }), ['tenancy.enabled: false']);
+ t('a clean schema carries no reason', detect({ name: 'sys_x', fields: {} }), []);
+ // ⭐ The failure-1 shape, pinned: two mechanisms on one object stay TWO.
+ t('an object carrying two mechanisms records both, not one',
+ detect({ managedBy: 'better-auth', tenancy: { enabled: false } }),
+ ["managedBy: 'better-auth'", 'tenancy.enabled: false']);
+ // A near-miss that must NOT be read as an opt-out: the audit half is off and
+ // the tenant half is untouched.
+ t('systemFields.audit: false is not a tenancy exclusion', detect({ systemFields: { audit: false } }), []);
+
+ // The live census, and the invariants that hold whatever the digits are.
+ const census = runCensus(root);
+ t('the population is non-empty', census.totals.registered > 0);
+ t('the totals partition the population',
+ census.totals.inReach + census.totals.outOfReach === census.totals.registered);
+ t('every row is one of the two verdicts',
+ census.objects.every((r) => r.reach === 'in' || r.reach === 'out'));
+ t('every row names a platform-namespace object',
+ census.objects.every((r) => PLATFORM_NAMESPACE.test(r.name)));
+ t('an in-reach row names the column it is reached by',
+ census.objects.filter((r) => r.reach === 'in').every((r) => typeof r.tenantField === 'string' && r.tenantField.length > 0));
+ t('an out-of-reach row names no column',
+ census.objects.filter((r) => r.reach === 'out').every((r) => r.tenantField === null));
+ t('the live tree has no unexplained exclusion', unexplainedExclusions(census), []);
+ t('reasonTotals counts objects, never sums to the total by construction',
+ Object.values(census.reasonTotals).every((n) => n > 0 && n <= census.totals.outOfReach));
+
+ // ⭐ POSITIVE CONTROL for the announce-never-absorb rule. A clean tree cannot
+ // exercise it -- it has no unexplained exclusion, by the assertion above -- so
+ // the adversarial input is supplied here: a census whose out-of-reach row has
+ // no reason must be REFUSED by the writer, not written with a shorter list.
+ const planted = {
+ ...census,
+ objects: [...census.objects, { name: 'sys_planted_probe', file: 'x.object.ts', reach: 'out', tenantField: null, reasons: [] }],
+ };
+ t('an unexplained exclusion is seen', unexplainedExclusions(planted), ['sys_planted_probe']);
+ let refused = null;
+ try {
+ writeArtefact(root, planted);
+ } catch (e) {
+ refused = String(e.message);
+ }
+ t('…and the writer REFUSES it rather than committing a shorter reason list',
+ refused !== null && refused.includes('sys_planted_probe') && refused.includes('REFUSING to write'));
+
+ // The artefact on disk is the one this generator would produce. (The gate
+ // re-runs this comparison as its production verdict; here it guards the
+ // RENDERER -- a formatting change that silently reddens every future run.)
+ t('the committed artefact is byte-identical to a fresh render',
+ readFileSync(join(root, ARTEFACT), 'utf8') === renderArtefact(census));
+
+ console.log(failures === 0
+ ? `✓ platform-object-tenancy-census generator self-test: all checks pass (${census.totals.registered} objects)`
+ : `✗ platform-object-tenancy-census generator self-test: ${failures} check(s) failed`);
+ return failures === 0 ? 0 : 1;
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// CLI
+// ─────────────────────────────────────────────────────────────────────────────
+
+export function main(argv = []) {
+ if (argv.includes('--self-test')) {
+ console.error(
+ 'platform-object-tenancy-census: this module exposes no --self-test flag ON PURPOSE (a flag would make '
+ + 'it a gate file and its path literals would stop being inherited by the gate that imports it). Run '
+ + '`node scripts/check-platform-object-tenancy-census.mjs --self-test`, which calls selfTest() here.',
+ );
+ return 2;
+ }
+ const census = runCensus(ROOT);
+ if (argv.includes('--write')) {
+ writeArtefact(ROOT, census);
+ console.log(`platform-object-tenancy-census: wrote ${ARTEFACT}`);
+ return 0;
+ }
+ if (argv.includes('--json')) {
+ console.log(JSON.stringify(census, null, 2));
+ return 0;
+ }
+ const { registered, inReach, outOfReach } = census.totals;
+ console.log('platform-object tenancy census');
+ console.log(` predicate resolveTenantFieldName(registered schema) !== null`);
+ console.log(` registered ${registered} platform-namespace objects`);
+ console.log(` in reach ${inReach}`);
+ console.log(` out ${outOfReach}`);
+ for (const [reason, n] of Object.entries(census.reasonTotals)) console.log(` ${n} ${reason}`);
+ const unexplained = unexplainedExclusions(census);
+ if (unexplained.length > 0) console.log(` ⚠ unexplained ${unexplained.join(', ')}`);
+ return 0;
+}
+
+if (isEntrypoint(import.meta.url)) process.exit(main(process.argv.slice(2)));
diff --git a/scripts/regen-artifacts.mjs b/scripts/regen-artifacts.mjs
index ed34c4e27d..987db802ea 100644
--- a/scripts/regen-artifacts.mjs
+++ b/scripts/regen-artifacts.mjs
@@ -341,6 +341,36 @@ export const REGEN_ARTIFACTS = Object.freeze([
gen: 'gen:react-blocks',
check: 'check:react-blocks',
},
+ // #14957. The platform-object tenancy census — which platform-namespace objects
+ // the tenancy machinery can reach, and the declaration on each excluded object's
+ // own schema that puts it outside. Generated WHOLE: `renderArtefact` renders the
+ // entire file in memory, its `$comment` preamble included, so nothing on disk
+ // survives into the output and there is no hand-written region for a deferral to
+ // launder — which is the question this table exists to ask. ⇒ `REGEN_ARTIFACTS`
+ // rather than `NOT_DRIVER_MANAGED`, and no `mixed`.
+ //
+ // Two PRs that each add an object land disjoint row sets; the merged tree's
+ // census equals NEITHER side, and its `totals` / `reasonTotals` equal neither
+ // either — the same "no text merge can reach the answer" property as its
+ // neighbours above, sharpened by the aggregates, which a union of rows would
+ // leave arithmetically wrong while looking merged.
+ //
+ // Same cheap-half caveat as its neighbours, same answer: the driver is LOCAL and
+ // is never the protection. `check:platform-object-tenancy-census` runs in
+ // `lint.yml` on `pull_request` and `merge_group` with no `paths:` filter, and it
+ // RE-DERIVES the census from the tree rather than reading the file back, so it
+ // catches the silent case too — two branches whose rows do not overlap merging to
+ // exit 0 over a file describing neither side.
+ //
+ // No `readsDist`/`readsSchemaTree`: the generator loads its predicate and every
+ // object declaration from SOURCE (no build), so a merged tree is the whole
+ // prerequisite.
+ {
+ path: 'scripts/platform-object-tenancy-census.json',
+ gen: 'gen:platform-object-tenancy-census',
+ check: 'check:platform-object-tenancy-census',
+ owner: ROOT_OWNER,
+ },
]);
/**