From 4ecc5a466f51004c38b1f18b0bcefc969e6d7e61 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 00:56:38 +0000 Subject: [PATCH 1/3] =?UTF-8?q?docs(audits):=20#14096=20census=20=E2=80=94?= =?UTF-8?q?=20ledger=20vs=20namespace=20regexp=20disagree=20on=208=20objec?= =?UTF-8?q?ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ruling item 1 (director seat, 总监批 #25, 2026-09-01, maintainer verbatim 「同意」): count how many platform-namespace objects the hand-adjudicated tenancy ledger (`PLATFORM_OBJECT_TENANCY`) judges differently than the namespace regexp `/^(sys_|cloud_|ai_)/` that both seed paths still use. Result: 8, not 0 — sys_file, sys_upload_session, sys_approval_request, sys_approval_action, sys_approval_approver, sys_automation_run, sys_notification_delivery, sys_record_share. Per ruling item 3 this STOPS here: no option (A/B/C) is implemented, no seed path is touched, no row on disk is touched. The full predicate, the header control (84/25/59 reproduced), the three-site re-derivation and the disagreement table are in the audit doc; the census script is self-contained and re-runnable. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 --- ...-tenancy-ledger-namespace-regexp-census.md | 249 ++++++++++++++++++ ...tenancy-ledger-namespace-regexp-census.mjs | 246 +++++++++++++++++ 2 files changed, 495 insertions(+) create mode 100644 docs/audits/2026-09-tenancy-ledger-namespace-regexp-census.md create mode 100644 scripts/audits/14096-tenancy-ledger-namespace-regexp-census.mjs 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..158cc0014b --- /dev/null +++ b/docs/audits/2026-09-tenancy-ledger-namespace-regexp-census.md @@ -0,0 +1,249 @@ +# 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; its other outputs can be +trusted. + +--- + +## 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. + +--- + +## 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..d1c5fd9f8d --- /dev/null +++ b/scripts/audits/14096-tenancy-ledger-namespace-regexp-census.mjs @@ -0,0 +1,246 @@ +#!/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. Exit 0 always; 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. + * + * ## 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')); + + 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: Object.keys(PLATFORM_OBJECT_TENANCY).length, + 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; }); From 8f4bb88b42329c875028a477d826cd032963c3e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 01:47:30 +0000 Subject: [PATCH 2/3] fix(audits): guard #14096 census against a silent-zero broken ledger read Reviewer on PR #15122 reproduced a silent-failure mode: emptying PLATFORM_OBJECT_TENANCY to {} and rebuilding still prints reproduced_matches_header: true and exits 0, with disagreement_count silently reading 0 -- indistinguishable from a genuine zero. The header control never reads the ledger (it's an AST census of the object files), so it validates the population and tenant-field logic and proves nothing about the ledger import. Add a hard floor (LEDGER_ENTRY_COUNT_FLOOR = 5, current ledger has 9 entries) on the imported PLATFORM_OBJECT_TENANCY: below it, the script throws a loud, explicit error instead of reporting a clean zero. Also correct the audit doc's "its other outputs can be trusted" sentence to say precisely what the control does and does not cover, and add a "What this census does not claim" section scoping the result (does not measure actual row counts on any deployment, or when each ledger verdict was decided relative to the seed paths' code) without softening it -- the count is still 8, confirmed three ways. Reproduced the reviewer's break on this branch (empty ledger -> rebuild -> exit 0, disagreement 0), applied the fix, reproduced again (same mutation -> rebuild -> now exit 1, loud error), then restored the ledger file and proved the restore by git hash-object equality against git rev-parse HEAD, plus empty git diff HEAD and git status --porcelain. Re-ran on the real, rebuilt ledger: still reports 8. Part of #14096 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 --- ...-tenancy-ledger-namespace-regexp-census.md | 43 ++++++++++++++- ...tenancy-ledger-namespace-regexp-census.mjs | 55 +++++++++++++++++-- 2 files changed, 91 insertions(+), 7 deletions(-) diff --git a/docs/audits/2026-09-tenancy-ledger-namespace-regexp-census.md b/docs/audits/2026-09-tenancy-ledger-namespace-regexp-census.md index 158cc0014b..140f69b9c8 100644 --- a/docs/audits/2026-09-tenancy-ledger-namespace-regexp-census.md +++ b/docs/audits/2026-09-tenancy-ledger-namespace-regexp-census.md @@ -106,8 +106,18 @@ comment in the script rather than quietly folded away. } ``` -**Control: PASSES.** The instrument reproduces the header exactly; its other outputs can be -trusted. +**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. --- @@ -227,6 +237,35 @@ 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). diff --git a/scripts/audits/14096-tenancy-ledger-namespace-regexp-census.mjs b/scripts/audits/14096-tenancy-ledger-namespace-regexp-census.mjs index d1c5fd9f8d..3627ac954b 100644 --- a/scripts/audits/14096-tenancy-ledger-namespace-regexp-census.mjs +++ b/scripts/audits/14096-tenancy-ledger-namespace-regexp-census.mjs @@ -11,10 +11,26 @@ * SEED paths — `seed-loader.ts`'s `fallbackOrgId` and * `seed-tenancy-backfill.ts`'s own `PLATFORM_NAMESPACE` — still cut by)? * - * MEASUREMENT ONLY. Exit 0 always; 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. + * 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) * @@ -203,6 +219,35 @@ async function main() { 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 dependency closure (`pnpm --filter \'@objectstack/objectql^...\' ' + + 'build && pnpm --filter @objectstack/objectql build`) 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 @@ -226,7 +271,7 @@ async function main() { 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: Object.keys(PLATFORM_OBJECT_TENANCY).length, + ledger_entry_count: ledgerEntryCount, disagreement_count: disagreements.length, disagreements: disagreements.map((d) => d.name), trap_check: trapObjects, From 0cfc7c61a2238cb816dc86597647e325ec105271 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 01:55:55 +0000 Subject: [PATCH 3/3] fix(audits): stop check:pnpm-filter-targets red on the new error message My own error-message string in the last commit wrote the rebuild hint as `pnpm --filter '@objectstack/objectql^...' build`, using an escaped single quote (\') around the filter target. check:pnpm-filter-targets scans raw JS source text (masking comments, not string-literal escapes), so it read the escape sequence as `--filter \` and correctly flagged that as a filter naming no real package -- a real, self-inflicted finding, not a bug in the gate. Reworded to reference the existing documented commands (the module doc comment above, and the audit doc's "Commands to re-run this census" section) instead of respelling the pnpm invocation inline. Re-ran check:pnpm-filter-targets: green. Re-ran the script against the real ledger: still reports 8. Part of #14096 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 --- .../14096-tenancy-ledger-namespace-regexp-census.mjs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/scripts/audits/14096-tenancy-ledger-namespace-regexp-census.mjs b/scripts/audits/14096-tenancy-ledger-namespace-regexp-census.mjs index 3627ac954b..2f4013754f 100644 --- a/scripts/audits/14096-tenancy-ledger-namespace-regexp-census.mjs +++ b/scripts/audits/14096-tenancy-ledger-namespace-regexp-census.mjs @@ -241,10 +241,11 @@ async function main() { '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 dependency closure (`pnpm --filter \'@objectstack/objectql^...\' ' + - 'build && pnpm --filter @objectstack/objectql build`) 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.', + '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.', ); }