|
| 1 | +# Tenancy ledger vs. namespace regexp — the #14096 census |
| 2 | + |
| 3 | +**Date:** 2026-09-04 · **Base:** `origin/main` `35e94c96b` · **Scope:** measurement only — |
| 4 | +ships nothing, changes no shipped package's behaviour, touches no row already on disk. |
| 5 | +This is step 1 of the director-seat ruling on #14096 (总监批 #25, 2026-09-01, maintainer |
| 6 | +verbatim 「同意」, issue comment |
| 7 | +[5494594783](https://github.com/objectstack-ai/objectstack/issues/14096#issuecomment-5494594783)). |
| 8 | + |
| 9 | +> **The ruling, verbatim:** |
| 10 | +> |
| 11 | +> 1. **普查先行(派发的第一交付物,纯测量零裁决)**:读 `PLATFORM_OBJECT_TENANCY` 台账, |
| 12 | +> 对每个键跑 `/^(sys_|cloud_|ai_)/`,量出**台账与正则判得不同的对象数**; |
| 13 | +> 2. **零** ⇒ **A**:两条 seed 路径维持正则,把「维持现状」记录为被选择的决定 —— 两个 |
| 14 | +> 正则站点各加一行注记引用本裁决(满足 #13491 执行点 3「不做也是必须写下的决定」), |
| 15 | +> 卡关 completed,归档级降 p3; |
| 16 | +> 3. **非零** ⇒ ⛔ 停手,带对象清单回决策箱改判 p1 —— 那时这不是形式分歧,是两个写者 |
| 17 | +> 对活数据不同意;B/C 届时上桌,且与 **#13636**(分类体系第三态)**联裁**:⛔ 不在 |
| 18 | +> 分类体系可能要改的当口扩散它; |
| 19 | +> 4. **C 的反向判例入册**:#8686 自己的姿态「已铸出的只报告、从不重写」—— 同车道既有 |
| 20 | +> 裁定不支持对存量行的重写,C 即便届时也需独立强理由; |
| 21 | +> 5. Clause-②:普查与注记均 no;若走到 B/C 再按 diff 复判。 |
| 22 | +
|
| 23 | +--- |
| 24 | + |
| 25 | +## Answer in one line |
| 26 | + |
| 27 | +**The count is 8, not 0.** The ledger (`PLATFORM_OBJECT_TENANCY`, the runtime write path's |
| 28 | +source of truth) judges 8 platform-namespace objects `tenant-scoped` — in scope, an |
| 29 | +organization is derived or the write is refused — while both seed paths' namespace regexp |
| 30 | +(`/^(sys_|cloud_|ai_)/`) judges every one of those same 8 objects `global` — out of scope, |
| 31 | +stays org-less. **Per ruling item 3, this STOPS here: nothing is implemented.** The object |
| 32 | +list goes back to the decision box, re-graded p1, jointly with #13636 (confirmed still |
| 33 | +open, `pm:awaiting-maintainer`, its implementation PR #14923 closed unmerged on the |
| 34 | +maintainer's own instruction — so this lands beside a card already in the maintainer's |
| 35 | +hands, not merely returns to an empty one). **This is a correct outcome of the census, not |
| 36 | +a failure of it.** |
| 37 | + |
| 38 | +--- |
| 39 | + |
| 40 | +## 1. The predicate (stated per dispatch Zone 2.1) |
| 41 | + |
| 42 | +Two verdicts are being compared, and they need a common binary before "differ" means |
| 43 | +anything: |
| 44 | + |
| 45 | +- **The ledger's verdict**, `classifyPlatformObjectTenancy(name)` |
| 46 | + (`packages/objectql/src/tenancy/platform-object-tenancy.ts`), answers one of three |
| 47 | + states: `tenant-scoped`, `global`, or `unclassified`. |
| 48 | +- **The regexp's verdict.** Read at both sites that still use it (§3 below), `/^(sys_| |
| 49 | + cloud_|ai_)/` is applied as a **uniform** rule: every name it matches is treated as |
| 50 | + staying global/cross-tenant — no seed-load fallback organization, no backfill adoption. |
| 51 | + So for every object IN this census's population (every registered platform-namespace |
| 52 | + object — the population is defined BY the regexp match, see §2), the regexp's verdict is |
| 53 | + the same single answer: **out of scope**. |
| 54 | + |
| 55 | +**The honest mapping onto that binary:** `tenant-scoped` → *in scope* (disagrees with the |
| 56 | +regexp's uniform *out of scope*); `global` **and** `unclassified` → *out of scope* (both |
| 57 | +agree with the regexp). This is not a simplification chosen for convenience — it is what |
| 58 | +the ledger's own file documents `unclassified` as: "keeps TODAY'S behaviour exactly (the |
| 59 | +object stays outside the machinery, as the blanket guard had it)", i.e. the same runtime |
| 60 | +outcome as `global`. So **a disagreement is exactly a ledger entry classified |
| 61 | +`tenant-scoped`** — nothing about the `global`/`unclassified` split changes which |
| 62 | +population disagrees, since both map to the same binary answer the regexp already gives |
| 63 | +every one of them. |
| 64 | + |
| 65 | +This also means the answer does not depend on whether an unclassified object happens to |
| 66 | +carry a real tenant column (the trap in §4) — an unclassified object agrees with the |
| 67 | +regexp regardless, by the ledger's own declared policy, not by this census's choice. |
| 68 | + |
| 69 | +--- |
| 70 | + |
| 71 | +## 2. The population, and the positive control (dispatch Zone 2.3) |
| 72 | + |
| 73 | +**Method:** an AST-lite census of every `ObjectSchema.create(` call under `packages/` |
| 74 | +(excluding tests and the `migrations/registry.ts` / `migrations/entries/` prose files, |
| 75 | +which only *mention* the call in comments), filtered to a literal `name: '...'` matching |
| 76 | +`/^(sys_|cloud_|ai_)/`. Comments and string contents are blanked before paren-depth |
| 77 | +matching — a first pass without that step undercounted by 2 |
| 78 | +(`sys_oauth_client_resource`, `sys_oauth_resource`), both because a doc comment shaped like |
| 79 | +`(255, 768]` desyncs a naive bracket scan; see the script's own header comment for detail). |
| 80 | +Script: `scripts/audits/14096-tenancy-ledger-namespace-regexp-census.mjs`. |
| 81 | + |
| 82 | +**Result: 84 registered platform-namespace objects** — all `sys_`-prefixed; zero `cloud_` |
| 83 | +or `ai_` object schemas exist in this repository (the ledger's own comment explains why: |
| 84 | +the five `cloud_`-runtime objects live in the separate `cloud` repo, which this tree cannot |
| 85 | +read, and the `ai_` domain's spec types are metadata types, not `sys_`-style data objects). |
| 86 | + |
| 87 | +**Positive control** (`platform-object-tenancy.ts:32-37`, independently re-derived this |
| 88 | +week and confirmed correct): *"84 platform-namespace objects are registered … 25 resolve |
| 89 | +NO tenant field (24 `managedBy: 'better-auth'`, plus `sys_sso_provider`'s `tenancy.enabled: |
| 90 | +false`) … 59 carry a tenant column."* Reproducing "does this object resolve a tenant |
| 91 | +field" requires replicating `resolveTenantFieldName`'s real precedence |
| 92 | +(`system-write-organization.ts`), not a `managedBy` heuristic — this census's script does, |
| 93 | +and got it wrong once before matching: a first draft treated `tenancy.enabled: false` the |
| 94 | +same as the `managedBy` skip (i.e. overridable by a self-declared field) and undercounted |
| 95 | +by one (`sys_sso_provider` declares its own `organization_id` field, but |
| 96 | +`resolveTenantFieldName` checks `tenancy.enabled === false` FIRST and returns `null` |
| 97 | +**unconditionally**, before ever consulting `fields`). The header-reproduction check caught |
| 98 | +this — exactly the discipline Zone 2.3 asks for ("if your own reading cannot reproduce the |
| 99 | +header's numbers, your instrument is wrong, not the header") — and the fix is left as a |
| 100 | +comment in the script rather than quietly folded away. |
| 101 | + |
| 102 | +```json |
| 103 | +{ |
| 104 | + "reproduced": { "total": 84, "no_tenant_field": 25, "has_tenant_field": 59 }, |
| 105 | + "reproduced_matches_header": true |
| 106 | +} |
| 107 | +``` |
| 108 | + |
| 109 | +**Control: PASSES.** The instrument reproduces the header exactly — but say precisely what |
| 110 | +that proves. This control is an AST census of the **object files**; it never reads |
| 111 | +`PLATFORM_OBJECT_TENANCY` at all. So it validates the **population** (which objects exist) |
| 112 | +and the **tenant-field precedence logic** (`resolveTenantFieldName`'s real branch order, |
| 113 | +including the `sys_sso_provider` trap in §2 above) — real value, and it is what caught the |
| 114 | +precedence bug. It validates **nothing about the ledger import** in §5 below: an emptied, |
| 115 | +stale, or mis-imported `PLATFORM_OBJECT_TENANCY` reproduces this exact header |
| 116 | +(`reproduced_matches_header: true`) while `disagreement_count` silently reads 0, because |
| 117 | +`disagreement_count` is computed from a different input this control never touches. §5's |
| 118 | +disagreement count is checked by a separate hard floor |
| 119 | +(`LEDGER_ENTRY_COUNT_FLOOR` in the script — reproduced and demonstrated in PR review), not |
| 120 | +by this control passing. |
| 121 | + |
| 122 | +--- |
| 123 | + |
| 124 | +## 3. Re-deriving the three cut sites (dispatch Zone 2.5) |
| 125 | + |
| 126 | +The dispatch card measured these at `dda969cd71`; `origin/main` has moved to `35e94c96b` |
| 127 | +since (`dda969cd71` confirmed an ancestor of the current HEAD via `git merge-base |
| 128 | +--is-ancestor`). All three still exist and still cut exactly as described: |
| 129 | + |
| 130 | +| Site | File:line | What it does | |
| 131 | +|---|---|---| |
| 132 | +| **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. | |
| 133 | +| **`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. | |
| 134 | +| **`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. | |
| 135 | + |
| 136 | +`scripts/audits/14096-tenancy-ledger-namespace-regexp-census.mjs`'s `confirmSeedSitesCutByRegexp()` |
| 137 | +re-derives this mechanically (regex-matches the exact expressions above against the current |
| 138 | +file contents) rather than trusting the table: |
| 139 | + |
| 140 | +```json |
| 141 | +"seed_sites_still_cut_by_regexp": { "seedLoaderCuts": true, "backfillDeclares": true, "backfillFilters": true } |
| 142 | +``` |
| 143 | + |
| 144 | +All three sites are unchanged in shape from the card's description. The measurement wins if |
| 145 | +any of this had moved; none of it had. |
| 146 | + |
| 147 | +--- |
| 148 | + |
| 149 | +## 4. The trap this census does not fall into (dispatch Zone 2.2) |
| 150 | + |
| 151 | +`resolveTenantFieldName` never reads `managedBy`. Three better-auth-managed objects declare |
| 152 | +their own `organization_id` field and resolve a real tenant column despite the |
| 153 | +`managedBy: 'better-auth'` label: |
| 154 | + |
| 155 | +```json |
| 156 | +"trap_check": [ |
| 157 | + { "name": "sys_member", "resolvesTenantField": true, "ledgerVerdict": "unclassified" }, |
| 158 | + { "name": "sys_team", "resolvesTenantField": true, "ledgerVerdict": "unclassified" }, |
| 159 | + { "name": "sys_invitation", "resolvesTenantField": true, "ledgerVerdict": "unclassified" } |
| 160 | +] |
| 161 | +``` |
| 162 | + |
| 163 | +None of the three is a **disagreement** for this census's question, though — none is in |
| 164 | +the ledger at all (all three are `unclassified`), and `unclassified` maps to the same "out |
| 165 | +of scope" binary answer as `global` (§1). The trap matters for §2's control (getting |
| 166 | +"which objects carry a tenant column" right), not for the disagreement count itself. |
| 167 | + |
| 168 | +--- |
| 169 | + |
| 170 | +## 5. The disagreement — 8 objects, named (dispatch Zone 2.4) |
| 171 | + |
| 172 | +Computed from the real, built module (`classifyPlatformObjectTenancy`, imported from |
| 173 | +`@objectstack/objectql`'s public entry — not re-transcribed by hand), cross-checked against |
| 174 | +the ledger's own pinned test (`tenancy-by-object-classification.test.ts`'s |
| 175 | +`tenantScopedPlatformObjects()` assertion), which lists the identical 8 names. |
| 176 | + |
| 177 | +| # | Object | Ledger's verdict | Regexp's verdict | Which write path(s) diverge from the runtime path **today** | |
| 178 | +|---|---|---|---|---| |
| 179 | +| 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 | |
| 180 | +| 2 | `sys_upload_session` | `tenant-scoped` — `StorageMetadataStore` stamps `organization_id` on insert + update (#12928/#13178), forward-stamp-only | global | **Both** | |
| 181 | +| 3 | `sys_approval_request` | `tenant-scoped` — writer repaired (#10101/PR #11311), backfilled 2026-08-23 | global | **Both** | |
| 182 | +| 4 | `sys_approval_action` | `tenant-scoped` — child row of `sys_approval_request`, same order | global | **Both** | |
| 183 | +| 5 | `sys_approval_approver` | `tenant-scoped` — child row, same order | global | **Both** | |
| 184 | +| 6 | `sys_automation_run` | `tenant-scoped` — `ObjectStoreSuspendedRunStore` resolves + stamps organization | global | **Both** | |
| 185 | +| 7 | `sys_notification_delivery` | `tenant-scoped` — `SqlOutbox.enqueue` stamps from the derived notification organization (#11698) | global | **Both** | |
| 186 | +| 8 | `sys_record_share` | `tenant-scoped` — writer repaired, backfilled 2026-09-02, `SharingService.grant` (#14484) | global | **Both** | |
| 187 | + |
| 188 | +Every one of the 8 diverges from **both** seed paths identically, because both paths cut |
| 189 | +by the exact same regexp (§3) — there is no case here where only one of the two seed sites |
| 190 | +disagrees with the runtime path while the other agrees. |
| 191 | + |
| 192 | +`sys_permission_set` — the ledger's ninth entry, `global` — is **not** a disagreement: its |
| 193 | +verdict already matches the regexp's uniform "stays global" answer, which is exactly why |
| 194 | +#8672 named it as the case the old blanket exemption got right. |
| 195 | + |
| 196 | +```json |
| 197 | +{ |
| 198 | + "ledger_entry_count": 9, |
| 199 | + "disagreement_count": 8, |
| 200 | + "disagreements": [ |
| 201 | + "sys_approval_action", "sys_approval_approver", "sys_approval_request", |
| 202 | + "sys_automation_run", "sys_file", "sys_notification_delivery", |
| 203 | + "sys_record_share", "sys_upload_session" |
| 204 | + ] |
| 205 | +} |
| 206 | +``` |
| 207 | + |
| 208 | +--- |
| 209 | + |
| 210 | +## 6. Branch verdict |
| 211 | + |
| 212 | +**Non-zero (8) ⇒ ruling item 3: STOP.** No option is implemented — not A, not B, not C. |
| 213 | +Per the ruling: *"这不是形式分歧,是两个写者对活数据不同意"* ("this is not a formal |
| 214 | +disagreement — it is two writers disagreeing about live data"). Concretely: for all 8 |
| 215 | +objects, a system-context write on a `single`-posture install today **derives** and stamps |
| 216 | +the real organization at the runtime layer (or is **refused** on a walled posture), while a |
| 217 | +seed replay or a tenancy-backfill run on the very same objects still treats them as |
| 218 | +deliberately org-less. `sys_file` (repaired #12745) and `sys_record_share` (repaired |
| 219 | +#14484, backfilled 2026-09-02) are the sharpest instances: the runtime write path was |
| 220 | +specifically repaired because an org-less row on each was a measured defect, yet the seed |
| 221 | +paths' rule for the identical objects has not moved. |
| 222 | + |
| 223 | +**This card returns to the decision box, re-graded p1**, and — per ruling item 3 — **not |
| 224 | +alone**: #13636 (the third tenancy state the batch-#9 re-ruling cannot express) is |
| 225 | +confirmed still open, labeled `pm:awaiting-maintainer` and `priority:p1`, its |
| 226 | +implementation PR **#14923 closed unmerged** on the maintainer's own instruction earlier |
| 227 | +this week. So this census's non-zero result does not merely return #14096 to an empty |
| 228 | +decision box — it lands beside a card already sitting in the maintainer's hands, which the |
| 229 | +ruling itself flags as raising the stakes rather than lowering them. Options B and C stay |
| 230 | +off the table until then, and per ruling item 4, C also carries an independent, unmet |
| 231 | +burden: #8686's own posture — "already-minted rows are reported, never rewritten" — is a |
| 232 | +counter-precedent on the same lane, not a null result. |
| 233 | + |
| 234 | +**Nothing on disk was touched.** No seed path was edited, no annotation was added (ruling |
| 235 | +item 2's annotations are conditioned on a **zero** result), no row was rewritten or |
| 236 | +backfilled. This document and its companion script are the entire diff. |
| 237 | + |
| 238 | +--- |
| 239 | + |
| 240 | +## 7. What this census does not claim |
| 241 | + |
| 242 | +The disagreement is real and the count is 8 — three independent routes agree on it (a hand |
| 243 | +count against §5's table, the ledger's own pinned test |
| 244 | +(`tenancy-by-object-classification.test.ts:162-171`), and the script). This section states |
| 245 | +this census's boundaries; it does not soften that result. Specifically, this census |
| 246 | +measured a **ledger-vs-regexp classification disagreement** — a static comparison of two |
| 247 | +verdicts in code — and it did **not** measure: |
| 248 | + |
| 249 | +- **How many rows the two seed paths have actually written for these 8 objects, on any |
| 250 | + deployment.** The disagreement is a code-level fact (what each write path *would do* on |
| 251 | + the next seed load or backfill run); it says nothing about how many org-less rows already |
| 252 | + exist for `sys_upload_session` or `sys_automation_run` on a given install, or whether a |
| 253 | + seed/backfill run has touched them recently enough for the gap to matter in practice. That |
| 254 | + is a separate, deployment-specific measurement this census did not take. |
| 255 | +- **When each ledger verdict was decided relative to the seed paths' code**, i.e. whether |
| 256 | + a given object is a case of "the ledger later tightened (a writer was repaired and the |
| 257 | + ledger updated to match) and the seed paths did not follow," or "the seed paths were |
| 258 | + already out of step when the ledger entry was written." Those two histories imply |
| 259 | + different remedies — the first is a follow-up patch to two already-known cut sites, the |
| 260 | + second may indicate the seed paths were never reviewed against the ledger's per-object |
| 261 | + policy at all — and neither was measured here: §5's table cites *when each writer was |
| 262 | + repaired*, not *when the seed paths were last checked against that repair*. |
| 263 | + |
| 264 | +Both are legitimate next questions if this card reaches option B or C; this census's scope |
| 265 | +(ruling item 1, a static classification count) does not extend to either. |
| 266 | + |
| 267 | +--- |
| 268 | + |
| 269 | +## Clause-② and changeset |
| 270 | + |
| 271 | +**Clause-②: no**, per ruling item 5 (census + non-zero-stop carries no annotation either). |
| 272 | +Re-derived against the actual diff: this PR adds one markdown file and one `.mjs` script |
| 273 | +under `scripts/audits/` — no exported symbol, no published payload key, nothing shipped. |
| 274 | +**`skip-changeset`**: a stop-at-non-zero outcome with no code change is explicitly the |
| 275 | +"publishes nothing" case the dispatch names for this label. |
| 276 | + |
| 277 | +--- |
| 278 | + |
| 279 | +## Commands to re-run this census |
| 280 | + |
| 281 | +```bash |
| 282 | +# Build the dependency closure the script imports from (dist, not src): |
| 283 | +pnpm --filter '@objectstack/objectql^...' build && pnpm --filter @objectstack/objectql build |
| 284 | + |
| 285 | +# The whole census, in one command — prints the full JSON report to stdout |
| 286 | +# and a human-readable summary to stderr; exits 0 (measurement, not a gate): |
| 287 | +node scripts/audits/14096-tenancy-ledger-namespace-regexp-census.mjs |
| 288 | +``` |
0 commit comments