Skip to content

Commit fcc42e6

Browse files
os-muskclaude
andauthored
docs(audits): #14096 census — tenancy ledger disagrees with namespace regexp on 8 objects (#15122)
* docs(audits): #14096 census — ledger vs namespace regexp disagree on 8 objects 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f3bbbef commit fcc42e6

2 files changed

Lines changed: 580 additions & 0 deletions

File tree

Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
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

Comments
 (0)