diff --git a/docs/audits/2026-09-tenancy-ledger-namespace-regexp-census.md b/docs/audits/2026-09-tenancy-ledger-namespace-regexp-census.md new file mode 100644 index 0000000000..140f69b9c8 --- /dev/null +++ b/docs/audits/2026-09-tenancy-ledger-namespace-regexp-census.md @@ -0,0 +1,288 @@ +# Tenancy ledger vs. namespace regexp — the #14096 census + +**Date:** 2026-09-04 · **Base:** `origin/main` `35e94c96b` · **Scope:** measurement only — +ships nothing, changes no shipped package's behaviour, touches no row already on disk. +This is step 1 of the director-seat ruling on #14096 (总监批 #25, 2026-09-01, maintainer +verbatim 「同意」, issue comment +[5494594783](https://github.com/objectstack-ai/objectstack/issues/14096#issuecomment-5494594783)). + +> **The ruling, verbatim:** +> +> 1. **普查先行(派发的第一交付物,纯测量零裁决)**:读 `PLATFORM_OBJECT_TENANCY` 台账, +> 对每个键跑 `/^(sys_|cloud_|ai_)/`,量出**台账与正则判得不同的对象数**; +> 2. **零** ⇒ **A**:两条 seed 路径维持正则,把「维持现状」记录为被选择的决定 —— 两个 +> 正则站点各加一行注记引用本裁决(满足 #13491 执行点 3「不做也是必须写下的决定」), +> 卡关 completed,归档级降 p3; +> 3. **非零** ⇒ ⛔ 停手,带对象清单回决策箱改判 p1 —— 那时这不是形式分歧,是两个写者 +> 对活数据不同意;B/C 届时上桌,且与 **#13636**(分类体系第三态)**联裁**:⛔ 不在 +> 分类体系可能要改的当口扩散它; +> 4. **C 的反向判例入册**:#8686 自己的姿态「已铸出的只报告、从不重写」—— 同车道既有 +> 裁定不支持对存量行的重写,C 即便届时也需独立强理由; +> 5. Clause-②:普查与注记均 no;若走到 B/C 再按 diff 复判。 + +--- + +## Answer in one line + +**The count is 8, not 0.** The ledger (`PLATFORM_OBJECT_TENANCY`, the runtime write path's +source of truth) judges 8 platform-namespace objects `tenant-scoped` — in scope, an +organization is derived or the write is refused — while both seed paths' namespace regexp +(`/^(sys_|cloud_|ai_)/`) judges every one of those same 8 objects `global` — out of scope, +stays org-less. **Per ruling item 3, this STOPS here: nothing is implemented.** The object +list goes back to the decision box, re-graded p1, jointly with #13636 (confirmed still +open, `pm:awaiting-maintainer`, its implementation PR #14923 closed unmerged on the +maintainer's own instruction — so this lands beside a card already in the maintainer's +hands, not merely returns to an empty one). **This is a correct outcome of the census, not +a failure of it.** + +--- + +## 1. The predicate (stated per dispatch Zone 2.1) + +Two verdicts are being compared, and they need a common binary before "differ" means +anything: + +- **The ledger's verdict**, `classifyPlatformObjectTenancy(name)` + (`packages/objectql/src/tenancy/platform-object-tenancy.ts`), answers one of three + states: `tenant-scoped`, `global`, or `unclassified`. +- **The regexp's verdict.** Read at both sites that still use it (§3 below), `/^(sys_| + cloud_|ai_)/` is applied as a **uniform** rule: every name it matches is treated as + staying global/cross-tenant — no seed-load fallback organization, no backfill adoption. + So for every object IN this census's population (every registered platform-namespace + object — the population is defined BY the regexp match, see §2), the regexp's verdict is + the same single answer: **out of scope**. + +**The honest mapping onto that binary:** `tenant-scoped` → *in scope* (disagrees with the +regexp's uniform *out of scope*); `global` **and** `unclassified` → *out of scope* (both +agree with the regexp). This is not a simplification chosen for convenience — it is what +the ledger's own file documents `unclassified` as: "keeps TODAY'S behaviour exactly (the +object stays outside the machinery, as the blanket guard had it)", i.e. the same runtime +outcome as `global`. So **a disagreement is exactly a ledger entry classified +`tenant-scoped`** — nothing about the `global`/`unclassified` split changes which +population disagrees, since both map to the same binary answer the regexp already gives +every one of them. + +This also means the answer does not depend on whether an unclassified object happens to +carry a real tenant column (the trap in §4) — an unclassified object agrees with the +regexp regardless, by the ledger's own declared policy, not by this census's choice. + +--- + +## 2. The population, and the positive control (dispatch Zone 2.3) + +**Method:** an AST-lite census of every `ObjectSchema.create(` call under `packages/` +(excluding tests and the `migrations/registry.ts` / `migrations/entries/` prose files, +which only *mention* the call in comments), filtered to a literal `name: '...'` matching +`/^(sys_|cloud_|ai_)/`. Comments and string contents are blanked before paren-depth +matching — a first pass without that step undercounted by 2 +(`sys_oauth_client_resource`, `sys_oauth_resource`), both because a doc comment shaped like +`(255, 768]` desyncs a naive bracket scan; see the script's own header comment for detail). +Script: `scripts/audits/14096-tenancy-ledger-namespace-regexp-census.mjs`. + +**Result: 84 registered platform-namespace objects** — all `sys_`-prefixed; zero `cloud_` +or `ai_` object schemas exist in this repository (the ledger's own comment explains why: +the five `cloud_`-runtime objects live in the separate `cloud` repo, which this tree cannot +read, and the `ai_` domain's spec types are metadata types, not `sys_`-style data objects). + +**Positive control** (`platform-object-tenancy.ts:32-37`, independently re-derived this +week and confirmed correct): *"84 platform-namespace objects are registered … 25 resolve +NO tenant field (24 `managedBy: 'better-auth'`, plus `sys_sso_provider`'s `tenancy.enabled: +false`) … 59 carry a tenant column."* Reproducing "does this object resolve a tenant +field" requires replicating `resolveTenantFieldName`'s real precedence +(`system-write-organization.ts`), not a `managedBy` heuristic — this census's script does, +and got it wrong once before matching: a first draft treated `tenancy.enabled: false` the +same as the `managedBy` skip (i.e. overridable by a self-declared field) and undercounted +by one (`sys_sso_provider` declares its own `organization_id` field, but +`resolveTenantFieldName` checks `tenancy.enabled === false` FIRST and returns `null` +**unconditionally**, before ever consulting `fields`). The header-reproduction check caught +this — exactly the discipline Zone 2.3 asks for ("if your own reading cannot reproduce the +header's numbers, your instrument is wrong, not the header") — and the fix is left as a +comment in the script rather than quietly folded away. + +```json +{ + "reproduced": { "total": 84, "no_tenant_field": 25, "has_tenant_field": 59 }, + "reproduced_matches_header": true +} +``` + +**Control: PASSES.** The instrument reproduces the header exactly — but say precisely what +that proves. This control is an AST census of the **object files**; it never reads +`PLATFORM_OBJECT_TENANCY` at all. So it validates the **population** (which objects exist) +and the **tenant-field precedence logic** (`resolveTenantFieldName`'s real branch order, +including the `sys_sso_provider` trap in §2 above) — real value, and it is what caught the +precedence bug. It validates **nothing about the ledger import** in §5 below: an emptied, +stale, or mis-imported `PLATFORM_OBJECT_TENANCY` reproduces this exact header +(`reproduced_matches_header: true`) while `disagreement_count` silently reads 0, because +`disagreement_count` is computed from a different input this control never touches. §5's +disagreement count is checked by a separate hard floor +(`LEDGER_ENTRY_COUNT_FLOOR` in the script — reproduced and demonstrated in PR review), not +by this control passing. + +--- + +## 3. Re-deriving the three cut sites (dispatch Zone 2.5) + +The dispatch card measured these at `dda969cd71`; `origin/main` has moved to `35e94c96b` +since (`dda969cd71` confirmed an ancestor of the current HEAD via `git merge-base +--is-ancestor`). All three still exist and still cut exactly as described: + +| Site | File:line | What it does | +|---|---|---| +| **Runtime write path** | `packages/objectql/src/engine.ts:3744` (`isTenantAuditInScope`, the tenant-audit mute) and `:3963` (inside `resolveSystemInsertOrganization`, called from the insert path at `:9934`) | Both call `isPlatformObjectOutOfTenantAuditScope(object)`, which reads the **hand-adjudicated ledger** — per-object, not by namespace. | +| **`seed-loader.ts`** | `:311` declares `fallbackOrgId`; `:926` applies it: `(/^(sys_\|cloud_\|ai_)/.test(objectName) ? undefined : this.fallbackOrgId)` | A platform-namespace seed row **never** takes the single-org fallback — regardless of whether the object is tenant-scoped, global, or unclassified in the ledger. | +| **`seed-tenancy-backfill.ts`** | `:587` declares its own `PLATFORM_NAMESPACE = /^(sys_\|cloud_\|ai_)/`; `:1251` applies it: `.filter((r) => !PLATFORM_NAMESPACE.test(String(r.object)))` | A platform-namespace object's counter-split rows are **excluded** from adoption into an organization — same uniform rule. | + +`scripts/audits/14096-tenancy-ledger-namespace-regexp-census.mjs`'s `confirmSeedSitesCutByRegexp()` +re-derives this mechanically (regex-matches the exact expressions above against the current +file contents) rather than trusting the table: + +```json +"seed_sites_still_cut_by_regexp": { "seedLoaderCuts": true, "backfillDeclares": true, "backfillFilters": true } +``` + +All three sites are unchanged in shape from the card's description. The measurement wins if +any of this had moved; none of it had. + +--- + +## 4. The trap this census does not fall into (dispatch Zone 2.2) + +`resolveTenantFieldName` never reads `managedBy`. Three better-auth-managed objects declare +their own `organization_id` field and resolve a real tenant column despite the +`managedBy: 'better-auth'` label: + +```json +"trap_check": [ + { "name": "sys_member", "resolvesTenantField": true, "ledgerVerdict": "unclassified" }, + { "name": "sys_team", "resolvesTenantField": true, "ledgerVerdict": "unclassified" }, + { "name": "sys_invitation", "resolvesTenantField": true, "ledgerVerdict": "unclassified" } +] +``` + +None of the three is a **disagreement** for this census's question, though — none is in +the ledger at all (all three are `unclassified`), and `unclassified` maps to the same "out +of scope" binary answer as `global` (§1). The trap matters for §2's control (getting +"which objects carry a tenant column" right), not for the disagreement count itself. + +--- + +## 5. The disagreement — 8 objects, named (dispatch Zone 2.4) + +Computed from the real, built module (`classifyPlatformObjectTenancy`, imported from +`@objectstack/objectql`'s public entry — not re-transcribed by hand), cross-checked against +the ledger's own pinned test (`tenancy-by-object-classification.test.ts`'s +`tenantScopedPlatformObjects()` assertion), which lists the identical 8 names. + +| # | Object | Ledger's verdict | Regexp's verdict | Which write path(s) diverge from the runtime path **today** | +|---|---|---|---|---| +| 1 | `sys_file` | `tenant-scoped` — writer repaired (#12745), maintainer-ordered backfill 2026-08-28 | global (stays org-less) | **Both** seed-loader and seed-tenancy-backfill | +| 2 | `sys_upload_session` | `tenant-scoped` — `StorageMetadataStore` stamps `organization_id` on insert + update (#12928/#13178), forward-stamp-only | global | **Both** | +| 3 | `sys_approval_request` | `tenant-scoped` — writer repaired (#10101/PR #11311), backfilled 2026-08-23 | global | **Both** | +| 4 | `sys_approval_action` | `tenant-scoped` — child row of `sys_approval_request`, same order | global | **Both** | +| 5 | `sys_approval_approver` | `tenant-scoped` — child row, same order | global | **Both** | +| 6 | `sys_automation_run` | `tenant-scoped` — `ObjectStoreSuspendedRunStore` resolves + stamps organization | global | **Both** | +| 7 | `sys_notification_delivery` | `tenant-scoped` — `SqlOutbox.enqueue` stamps from the derived notification organization (#11698) | global | **Both** | +| 8 | `sys_record_share` | `tenant-scoped` — writer repaired, backfilled 2026-09-02, `SharingService.grant` (#14484) | global | **Both** | + +Every one of the 8 diverges from **both** seed paths identically, because both paths cut +by the exact same regexp (§3) — there is no case here where only one of the two seed sites +disagrees with the runtime path while the other agrees. + +`sys_permission_set` — the ledger's ninth entry, `global` — is **not** a disagreement: its +verdict already matches the regexp's uniform "stays global" answer, which is exactly why +#8672 named it as the case the old blanket exemption got right. + +```json +{ + "ledger_entry_count": 9, + "disagreement_count": 8, + "disagreements": [ + "sys_approval_action", "sys_approval_approver", "sys_approval_request", + "sys_automation_run", "sys_file", "sys_notification_delivery", + "sys_record_share", "sys_upload_session" + ] +} +``` + +--- + +## 6. Branch verdict + +**Non-zero (8) ⇒ ruling item 3: STOP.** No option is implemented — not A, not B, not C. +Per the ruling: *"这不是形式分歧,是两个写者对活数据不同意"* ("this is not a formal +disagreement — it is two writers disagreeing about live data"). Concretely: for all 8 +objects, a system-context write on a `single`-posture install today **derives** and stamps +the real organization at the runtime layer (or is **refused** on a walled posture), while a +seed replay or a tenancy-backfill run on the very same objects still treats them as +deliberately org-less. `sys_file` (repaired #12745) and `sys_record_share` (repaired +#14484, backfilled 2026-09-02) are the sharpest instances: the runtime write path was +specifically repaired because an org-less row on each was a measured defect, yet the seed +paths' rule for the identical objects has not moved. + +**This card returns to the decision box, re-graded p1**, and — per ruling item 3 — **not +alone**: #13636 (the third tenancy state the batch-#9 re-ruling cannot express) is +confirmed still open, labeled `pm:awaiting-maintainer` and `priority:p1`, its +implementation PR **#14923 closed unmerged** on the maintainer's own instruction earlier +this week. So this census's non-zero result does not merely return #14096 to an empty +decision box — it lands beside a card already sitting in the maintainer's hands, which the +ruling itself flags as raising the stakes rather than lowering them. Options B and C stay +off the table until then, and per ruling item 4, C also carries an independent, unmet +burden: #8686's own posture — "already-minted rows are reported, never rewritten" — is a +counter-precedent on the same lane, not a null result. + +**Nothing on disk was touched.** No seed path was edited, no annotation was added (ruling +item 2's annotations are conditioned on a **zero** result), no row was rewritten or +backfilled. This document and its companion script are the entire diff. + +--- + +## 7. What this census does not claim + +The disagreement is real and the count is 8 — three independent routes agree on it (a hand +count against §5's table, the ledger's own pinned test +(`tenancy-by-object-classification.test.ts:162-171`), and the script). This section states +this census's boundaries; it does not soften that result. Specifically, this census +measured a **ledger-vs-regexp classification disagreement** — a static comparison of two +verdicts in code — and it did **not** measure: + +- **How many rows the two seed paths have actually written for these 8 objects, on any + deployment.** The disagreement is a code-level fact (what each write path *would do* on + the next seed load or backfill run); it says nothing about how many org-less rows already + exist for `sys_upload_session` or `sys_automation_run` on a given install, or whether a + seed/backfill run has touched them recently enough for the gap to matter in practice. That + is a separate, deployment-specific measurement this census did not take. +- **When each ledger verdict was decided relative to the seed paths' code**, i.e. whether + a given object is a case of "the ledger later tightened (a writer was repaired and the + ledger updated to match) and the seed paths did not follow," or "the seed paths were + already out of step when the ledger entry was written." Those two histories imply + different remedies — the first is a follow-up patch to two already-known cut sites, the + second may indicate the seed paths were never reviewed against the ledger's per-object + policy at all — and neither was measured here: §5's table cites *when each writer was + repaired*, not *when the seed paths were last checked against that repair*. + +Both are legitimate next questions if this card reaches option B or C; this census's scope +(ruling item 1, a static classification count) does not extend to either. + +--- + +## Clause-② and changeset + +**Clause-②: no**, per ruling item 5 (census + non-zero-stop carries no annotation either). +Re-derived against the actual diff: this PR adds one markdown file and one `.mjs` script +under `scripts/audits/` — no exported symbol, no published payload key, nothing shipped. +**`skip-changeset`**: a stop-at-non-zero outcome with no code change is explicitly the +"publishes nothing" case the dispatch names for this label. + +--- + +## Commands to re-run this census + +```bash +# Build the dependency closure the script imports from (dist, not src): +pnpm --filter '@objectstack/objectql^...' build && pnpm --filter @objectstack/objectql build + +# The whole census, in one command — prints the full JSON report to stdout +# and a human-readable summary to stderr; exits 0 (measurement, not a gate): +node scripts/audits/14096-tenancy-ledger-namespace-regexp-census.mjs +``` diff --git a/scripts/audits/14096-tenancy-ledger-namespace-regexp-census.mjs b/scripts/audits/14096-tenancy-ledger-namespace-regexp-census.mjs new file mode 100644 index 0000000000..2f4013754f --- /dev/null +++ b/scripts/audits/14096-tenancy-ledger-namespace-regexp-census.mjs @@ -0,0 +1,292 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #14096 step 1 (census) — the maintainer ruling's whole first deliverable + * (director seat, 总监批 #25, 2026-09-01, maintainer verbatim 「同意」, issue + * comment 5494594783): does `PLATFORM_OBJECT_TENANCY` (the hand-adjudicated + * ledger the RUNTIME write path reads, `packages/objectql/src/tenancy/ + * platform-object-tenancy.ts`) judge any platform-namespace object + * DIFFERENTLY than the namespace regexp `/^(sys_|cloud_|ai_)/` (what the two + * SEED paths — `seed-loader.ts`'s `fallbackOrgId` and + * `seed-tenancy-backfill.ts`'s own `PLATFORM_NAMESPACE` — still cut by)? + * + * MEASUREMENT ONLY on a healthy read: exit 0, this is a census, not a gate. + * Requires the `@objectstack/objectql` dependency closure built (`pnpm + * --filter '@objectstack/objectql^...' build && pnpm --filter + * @objectstack/objectql build`) — this script does not build. + * + * ⚠️ NOT measurement-only on a BROKEN ledger read. `disagreement_count` is + * downstream of `PLATFORM_OBJECT_TENANCY`, the one input this script does not + * independently re-derive (§2's header control is an AST census of the + * OBJECT FILES — it never reads this ledger, so it validates the population + * and tenant-field logic and proves nothing about the ledger import). An + * emptied, stale, or mis-imported ledger therefore reports a clean + * `disagreement_count: 0` indistinguishable from a genuine zero, WHILE + * `reproduced_matches_header` still prints `true` — reproduced against a PR + * #15122 reviewer experiment: `PLATFORM_OBJECT_TENANCY = {}`, rebuilt, run; + * exit 0, header control "passes", 0 disagreements. Because the ruling this + * script serves branches on the number (a zero closes #14096 as "keep the + * regexp", non-zero re-opens it p1 — see the audit doc's `## Branch + * verdict`), a false zero would close a p1 tenancy question as decided with + * no visible sign anything had gone wrong. `LEDGER_ENTRY_COUNT_FLOOR` below + * turns that failure mode into a thrown error (non-zero exit) instead. + * + * ## The predicate (stated, not left implicit — dispatch Zone 2.1) + * + * The regexp is used at both seed sites as a UNIFORM rule: every name it + * matches stays global/cross-tenant (no fallback org at seed-load, no + * backfill adoption) — confirmed by reading both sites (see §3 below). So + * "the regexp's verdict" for every object IN the population this script + * builds (every registered platform-namespace object — the population is + * defined BY the regexp match) is uniformly "out of scope" (global). + * + * "The ledger's verdict" is `classifyPlatformObjectTenancy(name)`, which + * answers one of three states: `tenant-scoped` (in scope — the runtime path + * derives/refuses an organization for it), `global`, or `unclassified` (not + * adjudicated — stays out of scope, same as `global`, per the ledger's own + * documented policy). + * + * The honest mapping onto the regexp's binary: `tenant-scoped` -> "in scope" + * (disagrees with the regexp's uniform "out of scope"); `global` and + * `unclassified` both -> "out of scope" (agree with the regexp). A disagreement + * is therefore exactly a ledger entry classified `tenant-scoped` — nothing + * about `global` vs `unclassified` changes which population disagrees, since + * both map to the SAME binary answer the regexp gives every one of them. + * + * ## The trap this script does NOT fall into (dispatch Zone 2.2) + * + * `resolveTenantFieldName` never reads `managedBy`. Three better-auth-managed + * objects (`sys_member`, `sys_team`, `sys_invitation`) declare their OWN + * `organization_id` field despite `managedBy: 'better-auth'`, so they resolve + * a real tenant column (`declaresOrgId` below checks the object's OWN + * `fields:` block for a literal `organization_id: Field...`, not `managedBy`). + * This matters for the header control (§2) — it does NOT change the + * disagreement count (§4), because none of the three is in the ledger at all + * (`unclassified`), and `unclassified` already maps to "out of scope" same as + * `global` above. The script still computes it, printed for that control. + * + * ## A second precedence subtlety, caught only by failing to reproduce the + * header on a first pass (exactly the failure mode §2's own comment warns + * about, so kept as a comment rather than quietly fixed) + * + * `resolveTenantFieldName` checks `tenancy.enabled === false` FIRST and + * returns `null` UNCONDITIONALLY on it — before it ever looks at `fields`. + * So an object can carry `tenancy: { enabled: false }` **and** its own + * declared `organization_id` field (`sys_sso_provider` does exactly this) + * and still resolve NO tenant field: the explicit opt-out overrides a + * self-declared column, it does not merely withhold injection of one. A + * first draft of `hasResolvableTenantField` below read `declaresOrgId` + * through that branch (treating the opt-out like the `managedBy` skip, + * which does NOT override a self-declared field) and undercounted + * `no_tenant_field` by one (24 instead of 25) against the header control — + * caught by §2's own mismatch check, not by review. + */ + +import { readFileSync } from 'node:fs'; +import { execSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(HERE, '../..'); + +// --------------------------------------------------------------------------- +// §1. Discover every registered platform-namespace object by AST-lite census +// of `ObjectSchema.create(` calls — the same method the ledger's own +// header (`platform-object-tenancy.ts:32-37`) documents using. +// --------------------------------------------------------------------------- + +/** Blank out `//` and `/* *\/` comments and string contents (chars only, + * preserving quote delimiters) so a paren mentioned in PROSE ("(255, 768]") + * cannot desync a naive bracket-depth scan. This is the exact bug that made + * an earlier pass of this census undercount by 2 (`sys_oauth_client_resource`, + * `sys_oauth_resource` — both carry a numeric-range comment shaped like + * `(N, M]`) — left in as a comment because the failure mode is easy to + * reintroduce by "simplifying" this function. */ +function blankNonCode(src) { + let out = ''; + let i = 0; + const n = src.length; + let inSL = false, inML = false, inStr = null; + while (i < n) { + const c = src[i], c2 = src[i + 1]; + if (inSL) { out += c === '\n' ? c : ' '; if (c === '\n') inSL = false; i++; continue; } + if (inML) { if (c === '*' && c2 === '/') { out += ' '; i += 2; inML = false; continue; } out += c === '\n' ? c : ' '; i++; continue; } + if (inStr) { out += c; if (c === '\\') { out += src[i + 1] ?? ''; i += 2; continue; } if (c === inStr) inStr = null; i++; continue; } + if (c === '/' && c2 === '/') { inSL = true; out += ' '; i += 2; continue; } + if (c === '/' && c2 === '*') { inML = true; out += ' '; i += 2; continue; } + if (c === '"' || c === "'" || c === '`') { inStr = c; out += c; i++; continue; } + out += c; i++; + } + return out; +} + +function findObjectSchemaCreateCalls() { + const files = execSync( + `grep -rl "ObjectSchema.create(" packages/ --include="*.ts" | grep -v "\\.test\\.\\|node_modules\\|/migrations/registry.ts\\|/migrations/entries/"`, + { cwd: REPO_ROOT, encoding: 'utf8', maxBuffer: 1024 * 1024 * 50 }, + ).trim().split('\n').filter(Boolean); + + const results = []; + for (const file of files) { + const rawSrc = readFileSync(resolve(REPO_ROOT, file), 'utf8'); + const src = blankNonCode(rawSrc); + let idx = 0; + while (true) { + const start = src.indexOf('ObjectSchema.create(', idx); + if (start === -1) break; + const parenStart = src.indexOf('(', start + 'ObjectSchema.create'.length - 1); + let depth = 0, i = parenStart, end = -1; + for (; i < src.length; i++) { + if (src[i] === '(') depth++; + else if (src[i] === ')') { depth--; if (depth === 0) { end = i; break; } } + } + if (end === -1) { idx = start + 1; continue; } + const rawBlock = rawSrc.slice(parenStart, end + 1); + const m = rawBlock.match(/name:\s*['"]([a-zA-Z0-9_]+)['"]/); + if (m && /^(sys_|cloud_|ai_)/.test(m[1])) { + results.push({ + name: m[1], + file, + managedBy: rawBlock.match(/managedBy:\s*['"]([a-zA-Z0-9_-]+)['"]/)?.[1] ?? null, + tenancyEnabledFalse: /tenancy:\s*\{[^}]*enabled:\s*false/s.test(rawBlock), + systemFieldsFalse: /systemFields:\s*false/.test(rawBlock), + systemFieldsTenantFalse: /systemFields:\s*\{[^}]*tenant:\s*false/s.test(rawBlock), + declaresOrgId: /\borganization_id\s*:\s*Field\./.test(rawBlock), + }); + } + idx = end + 1; + } + } + return results; +} + +// --------------------------------------------------------------------------- +// §2. Reproduce the ledger header's positive control (84 / 25 / 59) — +// dispatch Zone 2.3: "if your own reading cannot reproduce the header's +// numbers, your instrument is wrong, not the header." +// +// Replicates `applySystemFields`'s real precedence +// (`packages/objectql/src/registry.ts`): the `organization_id` COLUMN is +// injected UNCONDITIONALLY except for `tenancy.enabled: false`, +// `systemFields: false`, `systemFields.tenant: false`, or +// `managedBy: 'better-auth'` (whose column layout is better-auth's own +// migrations) — and even then, an object that already DECLARES its own +// `organization_id` field keeps it regardless of the skip. +// --------------------------------------------------------------------------- +function hasResolvableTenantField(entry) { + // `resolveTenantFieldName`'s FIRST check, and it is UNCONDITIONAL: an + // explicit `tenancy.enabled: false` wins even over a self-declared + // `organization_id` field (see the module doc comment above). + if (entry.tenancyEnabledFalse) return false; + // Past that gate, a self-declared field always counts — this is what + // keeps `sys_member`/`sys_team`/`sys_invitation` OUT of this branch's + // false side despite `managedBy: 'better-auth'` (the Zone-2.2 trap). + if (entry.declaresOrgId) return true; + if (entry.managedBy === 'better-auth') return false; // injection skipped, no self-declared field + if (entry.systemFieldsFalse) return false; + if (entry.systemFieldsTenantFalse) return false; + return true; // injected unconditionally otherwise +} + +// --------------------------------------------------------------------------- +// §3. Confirm the two seed sites still cut by the SAME uniform regexp rule +// (dispatch Zone 2.5 — re-derive, don't assume). +// --------------------------------------------------------------------------- +function confirmSeedSitesCutByRegexp() { + const seedLoader = readFileSync(resolve(REPO_ROOT, 'packages/metadata-protocol/src/seed-loader.ts'), 'utf8'); + const backfill = readFileSync(resolve(REPO_ROOT, 'packages/metadata-protocol/src/migrations/seed-tenancy-backfill.ts'), 'utf8'); + const seedLoaderCuts = /\(\/\^\(sys_\|cloud_\|ai_\)\/\.test\(objectName\)\s*\?\s*undefined\s*:\s*this\.fallbackOrgId\)/.test(seedLoader); + const backfillDeclares = /const PLATFORM_NAMESPACE = \/\^\(sys_\|cloud_\|ai_\)\//.test(backfill); + const backfillFilters = /\.filter\(\(r\)\s*=>\s*!PLATFORM_NAMESPACE\.test\(String\(r\.object\)\)\)/.test(backfill); + return { seedLoaderCuts, backfillDeclares, backfillFilters }; +} + +// --------------------------------------------------------------------------- +// §4. The ledger's real verdict, from the REAL built module — not +// re-transcribed by hand. +// --------------------------------------------------------------------------- +async function main() { + const entries = findObjectSchemaCreateCalls(); + const names = entries.map((e) => e.name).sort(); + + const withTenantField = entries.filter(hasResolvableTenantField).map((e) => e.name).sort(); + const withoutTenantField = entries.filter((e) => !hasResolvableTenantField(e)).map((e) => e.name).sort(); + + const seedSites = confirmSeedSitesCutByRegexp(); + + const { classifyPlatformObjectTenancy, PLATFORM_OBJECT_TENANCY, isPlatformNamespaceObject } = + await import(resolve(REPO_ROOT, 'packages/objectql/dist/index.js')); + + // Hard floor on the ledger read (see the module doc comment above for why + // this is not optional): an empty, stale, or mis-imported + // `PLATFORM_OBJECT_TENANCY` must fail LOUDLY, not report a clean + // `disagreement_count: 0` that is indistinguishable from a genuine zero. + // As of 2026-09-04 the ledger holds 9 entries (8 tenant-scoped + + // `sys_permission_set` global) -- §5 of the audit doc. The floor is set + // well below that so a handful of future legitimate edits (a new verdict + // added, or even one or two retired) does not trip it, while zero -- and + // anything close enough to zero to be a broken read rather than a real + // ledger -- does. If the ledger legitimately shrinks below this floor, + // raise it deliberately in its own PR; do not lower it to silence a real + // failure. + const LEDGER_ENTRY_COUNT_FLOOR = 5; + const ledgerEntryCount = Object.keys(PLATFORM_OBJECT_TENANCY).length; + if (ledgerEntryCount < LEDGER_ENTRY_COUNT_FLOOR) { + throw new Error( + `PLATFORM_OBJECT_TENANCY imported with only ${ledgerEntryCount} ` + + `${ledgerEntryCount === 1 ? 'entry' : 'entries'} (floor: ${LEDGER_ENTRY_COUNT_FLOOR}). ` + + 'This looks like an empty, stale, or mis-imported ledger, not a real low-entry ' + + 'state -- refusing to compute a disagreement count against it, because a false ' + + 'zero here would silently close a p1 tenancy question as "decided, nothing to ' + + 'do" (see the module doc comment and the audit doc\'s `## Branch verdict`). ' + + 'Rebuild the @objectstack/objectql dependency closure (see the exact commands in ' + + "this file's module doc comment, or the audit doc's \"Commands to re-run this " + + 'census\" section) and re-run. If the ledger has legitimately shrunk below ' + + `${LEDGER_ENTRY_COUNT_FLOOR} entries, raise LEDGER_ENTRY_COUNT_FLOOR deliberately -- ` + + 'do not lower it to make a real failure go away.', + ); + } + + const allMatchRegexp = names.every((n) => isPlatformNamespaceObject(n)); + + const disagreements = names + .map((name) => ({ name, ledger: classifyPlatformObjectTenancy(name) })) + .filter((r) => r.ledger === 'tenant-scoped'); + + // The trap-check objects named in the dispatch (Zone 2.2): resolve a real + // tenant field despite `managedBy: 'better-auth'`, but are NOT in the + // ledger (unclassified) -- so they do not appear in `disagreements` above. + const trapObjects = ['sys_member', 'sys_team', 'sys_invitation'].map((n) => ({ + name: n, + resolvesTenantField: withTenantField.includes(n), + ledgerVerdict: classifyPlatformObjectTenancy(n), + })); + + const report = { + population_size: names.length, + header_claims: { total: 84, no_tenant_field: 25, has_tenant_field: 59 }, + reproduced: { total: names.length, no_tenant_field: withoutTenantField.length, has_tenant_field: withTenantField.length }, + reproduced_matches_header: + names.length === 84 && withoutTenantField.length === 25 && withTenantField.length === 59, + all_discovered_names_match_regexp: allMatchRegexp, + seed_sites_still_cut_by_regexp: seedSites, + ledger_entry_count: ledgerEntryCount, + disagreement_count: disagreements.length, + disagreements: disagreements.map((d) => d.name), + trap_check: trapObjects, + no_tenant_field_objects: withoutTenantField, + }; + + console.log(JSON.stringify(report, null, 2)); + console.error('\n--- human-readable summary ---'); + console.error(`Population (registered platform-namespace objects): ${report.population_size}`); + console.error(`Header claims 84/25/59; reproduced ${report.reproduced.total}/${report.reproduced.no_tenant_field}/${report.reproduced.has_tenant_field} -> ${report.reproduced_matches_header ? 'MATCH (control passes)' : 'MISMATCH (investigate before trusting anything else in this report)'}`); + console.error(`Both seed sites still cut by the uniform namespace regexp: ${JSON.stringify(seedSites)}`); + console.error(`Ledger vs regexp disagreement count: ${report.disagreement_count}`); + console.error(`Disagreeing objects: ${report.disagreements.join(', ') || '(none)'}`); + console.error(`Trap check (resolve a tenant field despite managedBy:'better-auth', but ledger-unclassified so NOT a disagreement): ${JSON.stringify(trapObjects)}`); +} + +main().catch((e) => { console.error(e); process.exitCode = 1; });