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, + }, ]); /**