From 5629e6f9883ce5ea7656e6d29de1ffec1cc9face Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 17:41:00 +0000 Subject: [PATCH 1/3] feat(scripts): derive and gate the platform-object tenancy census (#14957) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PLATFORM_OBJECT_TENANCY`'s header stated the census as prose — three hand-written digits plus a parenthetical attributing them — with nothing re-deriving it. It failed in both directions a prose count can. A wrong REASON behind a right total: the parenthetical named `sys_sso_provider`'s `tenancy.enabled: false` as an addition to the `managedBy: 'better-auth'` set it was already in, and left `sys_api_key`'s identical opt-out unnamed. `24 + 1 = 25` stayed right, so 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. The digits are deleted, not corrected. The header points at the derived artefact and states the predicate it was missing: `resolveTenantFieldName` answering non-null on the REGISTERED schema, after `applySystemFields` has injected the tenant column, because the injected column is what the engine sees. `managedBy` is not the predicate. The generator loads `resolveTenantFieldName` and `resolveInjectedSystemColumns` from source and EXECUTES them rather than re-spelling what they decide — a transcription would be a second copy of a rule the engine owns, free to drift while reading as authoritative. It needs no build, so the gate runs in the `lint` job. Each excluded object records the declarations on its OWN schema, verbatim and not mutually exclusive: an object carrying two keeps both, which is what makes the mis-attribution unrepeatable. An excluded object with no declared mechanism is an ERROR — the generator refuses to write the row and the gate reds — so a new exclusion mechanism is adjudicated, never absorbed into an existing bucket's total. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk --- .../platform-object-tenancy-census-derived.md | 13 + .github/workflows/lint.yml | 48 ++ package.json | 4 +- .../src/tenancy/platform-object-tenancy.ts | 39 +- .../check-platform-object-tenancy-census.mjs | 312 ++++++++ scripts/platform-object-tenancy-census.json | 687 ++++++++++++++++++ scripts/platform-object-tenancy-census.mjs | 613 ++++++++++++++++ 7 files changed, 1703 insertions(+), 13 deletions(-) create mode 100644 .changeset/platform-object-tenancy-census-derived.md create mode 100644 scripts/check-platform-object-tenancy-census.mjs create mode 100644 scripts/platform-object-tenancy-census.json create mode 100644 scripts/platform-object-tenancy-census.mjs 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/.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..13174d26ae --- /dev/null +++ b/scripts/platform-object-tenancy-census.mjs @@ -0,0 +1,613 @@ +#!/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 ts from 'typescript'; + +import { isEntrypoint } from './invoked-as.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'); + const js = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, + fileName: file, + }).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))); From a4c43b0548fe0d3df04ca98085b60225af839430 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 17:59:04 +0000 Subject: [PATCH 2/3] fix(scripts): route the census transpile through transpileChecked (#14957) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check:parse-guard` caught a raw `ts.transpileModule` in the generator. The raw call reports NOTHING on a source it could not read and still returns an `outputText`, so an unparseable module would have been evaluated as whatever survived and the census would have scored the tree against wreckage — with a clean exit and a smaller, confident number. That is this artefact's own failure class one layer down, so the fix is the sanctioned wrapper rather than a diagnostics flag: `transpileChecked` forces `reportDiagnostics: true` and refuses instead of emitting. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk --- scripts/platform-object-tenancy-census.mjs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/scripts/platform-object-tenancy-census.mjs b/scripts/platform-object-tenancy-census.mjs index 13174d26ae..0f55b84c70 100644 --- a/scripts/platform-object-tenancy-census.mjs +++ b/scripts/platform-object-tenancy-census.mjs @@ -111,9 +111,8 @@ import { createRequire } from 'node:module'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import ts from 'typescript'; - import { isEntrypoint } from './invoked-as.mjs'; +import { transpileChecked } from './ts-parse.mjs'; const ROOT = join(fileURLToPath(new URL('.', import.meta.url)), '..'); @@ -219,9 +218,13 @@ export function createSourceLoader(root = ROOT) { const hit = cache.get(file); if (hit) return hit.exports; const source = readFileSync(file, 'utf8'); - const js = ts.transpileModule(source, { - compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, - fileName: file, + // `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 From 001c8205ffdce96d6e7cb357759407a94451464e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 18:54:18 +0000 Subject: [PATCH 3/3] fix(scripts): register the tenancy census artefact with the merge driver (#14957) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI red on `pnpm check:merge-driver`: `gen:platform-object-tenancy-census` had no recorded merge disposition, and every generator must be in one of the two ledgers in `scripts/regen-artifacts.mjs`. The honest answer is `REGEN_ARTIFACTS`, not `NOT_DRIVER_MANAGED`. `renderArtefact` renders the whole file in memory, its `$comment` preamble included, so nothing on disk survives into the output and there is no hand-written region a deferral could launder — which is the question that table exists to ask. No `mixed` for the same reason. The routing earns its place: two PRs that each add an object land disjoint row sets, and the merged tree's census equals neither side — its `totals` and `reasonTotals` least of all, which a union of rows would leave arithmetically wrong while looking merged. ⚠️ The driver is LOCAL and is never the protection. The load-bearing half is `check:platform-object-tenancy-census` in `lint.yml`, on `pull_request` and `merge_group` with no `paths:` filter, re-deriving from the tree rather than reading the file back — so it also catches the silent case where two branches' rows do not overlap and the text merge exits 0 over a file describing neither. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk --- .gitattributes | 1 + scripts/regen-artifacts.mjs | 30 ++++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+) 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/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, + }, ]); /**