Skip to content

Commit e99a14c

Browse files
fix(census): scope the tenant-audit census on class-property and callable names (#19749)
Fixes #19652 Clause-②: no `scripts/tenant-audit-census.mjs` keyed two of its three name maps on the **bare identifier**. `thisProps` holds class property types; `fnReturns` holds what a callable returns. Two classes in one file sharing a property name were ONE entry and the first TYPED one decided for both, and every consumer looked the name up by bare name too. The census builds `content/docs/permissions/tenant-audit-census.mdx`, which ships under `content/docs/**`, so a wrong verdict publishes a call site to customers as tenancy-safe when it is not. ## The probe, re-taken on today's `origin/main` — not inherited from the card Taken at `origin/main` @ `16d090ede0`, 2026-09-22T20:17:28Z, before a line was changed. The card's probe is the #19300 dev's and the card says whoever takes this card re-takes it. Receiver verdicts in source order; `IProbeEngine` is in the engine type index, the other type is a Map keyed by string (`MAP_OF_STRING_TO_NUMBER`, spelled this way to keep the generic's angle brackets out of a GitHub body). | probe | shape | before | after | |:--|:--|:--|:--| | P1 | thisProps: two classes, constructor-injected `engine`, engine declared first | `engine/IProbeEngine \| engine/IProbeEngine` | `engine/IProbeEngine \| other/platform-type` | | P2 | thisProps: same pair, other declaration order | `other/platform-type \| other/platform-type` | `other/platform-type \| engine/IProbeEngine` | | P3 | thisProps: property DECLARATIONS rather than constructor parameters | `engine/IProbeEngine \| engine/IProbeEngine` | `engine/IProbeEngine \| other/platform-type` | | P4 | fnReturns: two same-named local functions, engine first | `engine/IProbeEngine \| engine/IProbeEngine` | `engine/IProbeEngine \| other/platform-type` | | P5 | fnReturns: same pair, other declaration order | `other/platform-type \| other/platform-type` | `other/platform-type \| engine/IProbeEngine` | P1 reproduces the card's prediction exactly: a `Map.delete` ADMITTED to the certified tenancy population. P2 and P5 are the direction the card predicted only in prose and the census header names as the one it cannot survive — a real engine write SUBTRACTED as `platform-type`, a DEFENDED arm that prints nothing and is counted nowhere. P3 is a storage site the card's probe does not reach. **Controls, all taken on the same instrument in the same run, all unchanged before and after.** FIRING: a lone Map-typed `this.engine` is refused (`other/platform-type`) and a lone engine-typed one is accepted (`engine/IProbeEngine`) — so the door is not inert in either direction. DISCRIMINATION: the same two types under DIFFERENT property names, and under different function names, are told apart. DARK: the identical shape on PARAMETERS is already correct, because that is the lexical tier #19300 landed (PR #19650) — so the failure is specific to the two maps this card names. DARK: with `IProbeEngine` removed from the index the same source answers `other/type-not-in-corpus`, so a verdict is not constant. ## The fix — the landed tier extended, not a second mechanism beside it `scopeChainOf` and `scopedNames` now take the scope predicate. All three name maps are `scopedNames` instances under the predicate that matches how the language scopes that kind of name: - `thisProps` — `opensClassScope` (class declarations and class expressions). `this.engine` in class `B` is `B`'s property and can be nothing else. - `fnReturns` — `opensCallableScope`, the lexical scopes PLUS the class. A method's name belongs to its class body; a `function` declaration's or a `const fn = …`'s to the block or file that declares it. Not the class-only predicate: a `function getEngine()` two lines above a class is not class-scoped, and a chain that saw only classes would lose it. - `locals` — unchanged. Each keeps that structure's file-wide FLOOR, so a name no enclosing scope declares resolves exactly where it used to and nothing that resolved before stops resolving. The bare-key spelling is REMOVED rather than guarded: `note()` now requires the declaring node, and no `.get(name)` lookup survives on either map. There is no spelling left to reach for by accident. ## Before/after on the published population — it did not move, and that is a measurement Full `--json` census, before and after, on the same tree: **227 write call sites, 153 decidable, 9 tenancy-enabled sites provably carrying no tenant context, 34 more unreadable — byte-identical.** Every array (`sites`, `nonEngineCalls`, `undefendedSubtractions`, `engineTypes`, `declaredObjects`) and `nonEngineReasons` are identical too. **Zero call sites change classification, in either direction.** That zero is measured rather than assumed. `scopedNames().lookup(name, null)` skips the scope chain and returns the file-wide tier, whose insertion rule is byte-for-byte the old bare-name `note()` — so comparing it against `lookup(name, node)` at every write-call receiver IS the before/after diff, on one code path: - **Corpus scan, 576 sources: 98 divergent receivers across 44 files — every one of them a `local`**, i.e. the tier #19300 already landed. The kinds this card changes (`this.PROP`, `this.BASE.member`, `fname()`) diverge at **0** receivers. The 98 is the firing control: the same instrument, the same corpus, the same run, answering non-zero. - Synthetic firing controls on that identical scan function return 1 each for the P1 and P4 shapes; dark controls (different names; a single declaration) return 0. **The conflating shape is nevertheless already in the corpus — the defect was latent, not absent.** The same instrument, applied at DECLARATION nodes, finds 73 shadowed declarations, 6 of them with genuinely conflicting declared types: | file:line | map | name | scoped reads | bare read | |:--|:--|:--|:--|:--| | `packages/services/service-messaging/src/http-outbox.ts:462` | thisProps | `code` | a three-arm literal union | `'DELIVERY_NOT_ELIGIBLE'` | | `packages/plugins/plugin-email/src/email-service.ts:644` | fnReturns | `send` | `SendEmailResult` | `TransportSendResult` | | `packages/services/service-sms/src/sms-service.ts:113` | fnReturns | `send` | `SendSmsResult` | `SmsTransportSendResult` | | `packages/services/service-settings/src/settings-service.ts:1509` | fnReturns | `get` | `T[K]` | a resolved-setting value | | `packages/services/service-automation/src/engine.ts:3239` | fnReturns | `execute` | no annotation | an automation result | | `packages/plugins/plugin-security/src/security-plugin.ts:5790` | fnReturns | `isScalarId` | a three-arm predicate | a two-arm predicate | None of those six names is the receiver of a write call today, which is exactly why the population does not move. One write call through any of them and it would have. ## Reverse verification — the pins can fail Eight self-test cases were added (67 to 75, all pass). Both ablations ran through `scripts/ablation-replace.mjs`, which proves the mutation landed on disk by anchor count and blob hash and proves the restore by blob-equals-HEAD plus an empty `git diff HEAD`: - `scopedNames(opensClassScope)` replaced by a predicate that opens nothing: **exactly the 4 class-scope cases go red**, each with the `got` string this PR measured at `origin/main` (`engine/IProbeEngine | engine/IProbeEngine`). The two FLOOR cases stay green. - Same for `opensCallableScope`: **exactly the 2 callable-scope cases go red.** The FLOOR cases stay green. ## The regenerated page `node scripts/tenant-audit-census.mjs --write` on the merged tree, the repo's own tooling, never a hand edit. It rewrites two lines in total — the unenforced corpus-scale block's provenance stamp in each artefact. Every ENFORCED figure is byte-identical. `node scripts/check-tenant-audit-census.mjs` exits 0: "227 write call sites certified (153 decidable; 9 tenancy-enabled sites PROVABLY carry no tenant context, 34 more unreadable), 23 prose figures held to the census". Neither `content/docs/permissions/tenant-audit-census.mdx` nor `docs/audits/2026-08-tenant-audit-write-call-sites.counts.md` is routed `merge=os-regen` (`git check-attr merge` answers `unspecified` for both, and for the script). `origin/main` was merged in as an ordinary merge with zero path overlap, so `scripts/pm/os-regen-merge.sh` has nothing to do here. ## Verification `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived **63 families** from this worktree on the final tree; **63 run, 0 NOT-MEASURED, 0 UNRUN, and none exited 3**, reconciled by `--ran` with every exit code recorded (captured before any pipe). Four of them first answered PREREQUISITE NOT MET on an unbuilt worktree and named what they needed; after the builds they named (`@objectstack/spec`, `@objectstack/formula`, `@objectstack/lint`, then `@objectstack/client`, `@objectstack/client-react`) all four are green. `pnpm lint` — the repo-wide `eslint . --no-inline-config`, CI's own scope, not a narrowing — exits 0 at `bee92afe12`. Two artifact-roster families whose roster sits under `scripts/` were run beyond the derived set: `pnpm check:dts-closure` exits 0 after the builds; `pnpm check:published-readme-exports` remains **exit 3 / NOT MEASURED**, needing every package's `dist` declarations, which a diff touching no package source cannot supply. It is not a pass and not a finding. ## Changeset **This PR needs the `skip-changeset` label, and this branch cannot apply it** — the dispatch order withholds label writes from this seat, so the seat that dispatched it owns that write. `Check Changeset` will sit red until then: the job counts changesets added against the merge base and exempts only the label or the changesets release branch, and this PR adds none. The reading behind that route, rather than a guess: 70 package manifests declare a `files[]`, 217 entries in all, and **0 of them reach outside their own package directory**. All three changed paths live at the repository root — `scripts/`, `content/docs/`, `docs/audits/` — outside every package directory, so no published tarball's bytes move. Positive control on the same matcher: `packages/spec/dist/index.js` resolves as SHIPPED by `packages/spec`. Dark control: the near-miss `scripts/tenant-audit-censuss.mjs` resolves as shipped by nothing. ## The callee-shape repair — a call site is resolved by how the CALL is written Commits `bfd92001b1` and `a36551a559`, verified independently on head `a36551a559` by a later round that re-derived every number below rather than inheriting it. `opensCallableScope` is the predicate a callable's name is RECORDED under: a method's name in its class body, a function declaration's in the block or file that declares it. The lookup site read that same union for **every** callee shape, so a bare `getEngine()` written inside `class C` resolved through `C`'s body — which the language never does, because a method lives on the prototype and not in lexical scope. `calleeScopeChain(callee)` now picks the chain by callee shape: a bare identifier walks the lexical scopes only, `this.m()` walks the class chain, and a `receiver.m()` call walks neither and reads the file-wide floor, which is exactly what it resolved through before. Recording is unchanged. ### The movement, measured on three trees Receiver verdicts for the same synthetic sources, taken through the census's own exported `declaredTypesIn` / `resolveReceiver` / `nonEngineReason` on three checkouts of this repository. `IProbeEngine` is in the engine type index; the other type is a Map keyed by string (`MAP_OF_STRING_TO_NUMBER`, spelled this way to keep the generic's angle brackets out of a GitHub body). | probe | shape | `origin/main` `c120dbdfb8` | pre-fix head `bee92afe12` | this head `a36551a559` | |:--|:--|:--|:--|:--| | X1 | bare `getEngine()` inside a class, function declared first | `engine/IProbeEngine` | `other/platform-type` | `engine/IProbeEngine` | | X2 | the same pair, class declared first | `other/platform-type` | `other/platform-type` | `engine/IProbeEngine` | | X3 | `this.getEngine()` beside a same-named file-level function | `engine/IProbeEngine` | `other/platform-type` | `other/platform-type` | | X4 | `this.x.getEngine()`, `x` an `A` returning an engine, inside a `B` with its own same-named method | `engine/IProbeEngine` | `other/platform-type` | `engine/IProbeEngine` | X1 and X4 are the regression the at-tier review found: a real engine write that `origin/main` PLACED was SUBTRACTED at the pre-fix head under the DEFENDED `platform-type` arm, which prints nothing and is counted nowhere — the direction this census's header names as the one it cannot survive. X2 is an improvement over both heads. X3 is the class-scope half this PR already landed, and the repair leaves it standing. **The language is the authority here, and it was asked.** `tsc --noEmit --strict` on the X1 source exits **0** — the bare call resolves to the file-level function, so `insert` exists on it — and on the X3 source exits **2** with `TS2339: Property 'insert' does not exist on type 'MAP_OF_STRING_TO_NUMBER'`, so a `this.` call does read the class's own method. The two verdicts this head gives are the two the compiler gives. ### The new pins can fail — four ablations, each proven on disk All four ran through `scripts/ablation-replace.mjs`: anchor count plus blob hash prove the mutation landed, blob-equals-HEAD plus an empty `git diff HEAD` prove the restore. There is no build leg to reach — `scripts/tenant-audit-census.mjs` is executed from source, so `ablation-dist-preflight` has no `dist` to preflight for it. | ablation | mutation | self-test result | |:--|:--|:--| | A0 | the lookup reverted to the pre-fix spelling, `lookup(fname, callee)` | **3 of 79 red** — X1, X2, X4, each `got other/platform-type`, exactly the pre-fix answer | | A1 | the `this.` arm returns the floor instead of the class chain | **1 of 79 red** — X3, `got engine/IProbeEngine` | | A2 | `opensClassScope` opens nothing | **6 of 79 red** — the 4 class-scope cases, plus X2 and X3 | | A3 | `opensCallableScope` opens nothing | **4 of 79 red** — the 2 callable-scope cases, plus X2 and X3 | A0 and A1 together red all four new cases, so not one of them is vacuously true. The four FLOOR cases stay green under A2 and A3, as they did before. **A2 and A3 now red more cases than they did before `bfd92001b1`, and that is the repair being load-bearing rather than a regression**: `calleeScopeChain` hands back those two predicates, so neutralising either one now also breaks the lookup that reads it. The earlier "exactly the 4" and "exactly the 2" readings in this body describe the tree at `bee92afe12` and no longer describe this head. Self-test case count: **67** on `origin/main`, **75** at `bee92afe12`, **79** here. `node scripts/check-tenant-audit-census.mjs --self-test` exits 0. ### The published population, re-measured — still zero, and still a measurement `runCensus({ root })` threads its root through every enumeration the module makes, so both instruments were run against **one tree** rather than two trees believed equal: the pre-fix module from `bee92afe12` and this head's module, both over this head's checkout. The `--json` bytes are **identical** — 112739 bytes, `sha256 1e7fed942bc2320893a926ed6299905547fcaadc7a97b13ab0accbe1d797d21f` — at **227 write call sites, 153 decidable, 74 undecidable, 576 sources, 144 non-engine subtractions, 0 unresolved receivers**. No call site changes classification, in either direction. The zero has controls, on the same comparison: - **FIRING** — add one tracked corpus file carrying the X1 shape and the comparison MOVES: the pre-fix instrument reports 227 sites with `platform-type 4`; this head reports 228 sites, 154 decidable, `platform-type 3`; and the diff names the added site. The subtraction the repair removes is exactly the one the review predicted. - **DARK** — the same augmented tree compared instrument-against-itself is byte-identical in both directions, so the movement is attributable to the instrument version and to nothing else. Remove the added file and the comparison returns to byte-identical. Across the two checkouts the only differing byte in the whole census object is the provenance `ref` it records (`a36551a55` against `bee92afe1`), which is what the re-stamp commit exists to move. ### The two re-stamped artefacts `node scripts/tenant-audit-census.mjs --write` at this head rewrites **the provenance stamp line and no other byte** in either artefact: re-run on a clean tree, the entire `git diff` is two lines, each a `Measured on 2026-09-22 at ...`. Every enforced figure is what the generator produces at this head, and the tree was restored to its HEAD blobs afterwards. `node scripts/check-tenant-audit-census.mjs` exits **0**: "227 write call sites certified (153 decidable; 9 tenancy-enabled sites PROVABLY carry no tenant context, 34 more unreadable), 23 prose figures held to the census". The committed stamp names `bfd92001b`, the last commit that changes the instrument — stamping the re-stamp commit itself would need a further commit that would in turn be unstamped, so this is the fixed point, and the gate deliberately does not compare that value. ### Verification, re-derived from this head `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` from a worktree at `a36551a559` derives **63 families** from 3 changed paths (248 changed lines, under the 5000 human-merge threshold). All 63 ran with each exit code captured before any pipe and reconciled by `--ran`: **63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN, none exited 3.** One family first answered exit 3 / PREREQUISITE NOT MET — `check:skill-examples`, which needs built `@objectstack/client` and `@objectstack/client-react` declarations; after building those packages' dependency closure it exits 0. `Lint & Repo Gates` reported **success** on `a36551a559` (run 35788706216) and again on `b73fd40aa0` (run 35797018139, job 106978668423, `success`). ⛔ Each reading names the commit it was taken on and is about no other: on `93555d0fcd` it reported **success** too (run 35802103477, 00:27:18Z → 00:57:46Z). ⚠️ An earlier revision of this body said no run had been scheduled on that head; that was stale when written. Its two census steps were also run directly at each head — `node scripts/check-tenant-audit-census.mjs` and the same script's `--self-test` — exit 0 each. ### The object-literal repair — a member name is not a lexical one on the RECORDING side either Commits `9a24005710`, `496d65dc79` and `b73fd40aa0`, answering the at-tier contract review's blocking finding F1. The callee-shape repair kept the class body off a bare call's lookup chain. That is only half of "a bare `f()` is never shadowed by a method": a name is reachable through whatever scope it was RECORDED at. `ts.isMethodDeclaration` is true of an OBJECT LITERAL's method as well as a class one, and an object literal opened no scope under `opensCallableScope` — so such a method was recorded as a lexical callable of the enclosing BLOCK, and a bare `getEngine()` written in that block resolved to the object's method. The language never does that. `opensCallableScope` now also opens `ts.isObjectLiteralExpression`, so a member name is recorded under ITS CONTAINER exactly as a class method's is. No lookup chain walks a container — `calleeScopeChain` reaches the class for `this.m()` and nothing for the rest — so an object-literal method stays reachable through the file-wide FLOOR exactly as before, and no receiver that resolved stops resolving. The `thisProps` / `opensClassScope` half, the `this.` arm and the floor are untouched. | probe | shape | `origin/main` `c120dbdfb8` | pre-fix head `a36551a559` | this head `b73fd40aa0` | `tsc` | |:--|:--|:--|:--|:--|:--| | E7 | file-level `function getEngine(): IProbeEngine`; inside `w()` a `const o = { getEngine(): MAP_OF_STRING_TO_NUMBER … }` and a bare `getEngine().insert(…)` in that block | `engine/IProbeEngine` | `other/platform-type` | **`engine/IProbeEngine`** | exit 0 (engine) | | E7r | the same, file-level function declared after `w()` | `other/platform-type` | `other/platform-type` | **`engine/IProbeEngine`** | exit 0 (engine) | Re-taken here rather than inherited (2026-09-22T23:11:48Z at `a36551a559`, 23:13:37Z after the fix), through the census's own `declaredTypesIn` / `resolveReceiver` / `nonEngineReason`. Controls on the same instrument and run: FIRING — the same block with no object literal reads `engine/IProbeEngine`, so the door is not inert; DISCRIMINATION — the same literal under a DIFFERENT member name reads `engine/IProbeEngine`; DARK — with `IProbeEngine` out of the index the fixed instrument reads `other/type-not-in-corpus`, so the verdict is not constant. `tsc --noEmit --strict` (6.0.3, `--ignoreConfig`) exits 0 on both E7 sources; on a third source where the object-literal method is the ONLY declaration it exits 2 with `TS2304: Cannot find name 'getEngine'` — the compiler does not put an object-literal method in lexical scope at all. **The union is now closed for this corpus, measured rather than argued.** Over all 576 sources at this head, every identifier-named `MethodDeclaration` is parented by `ClassDeclaration` (1325) or `ObjectLiteralExpression` (98), and every `FunctionDeclaration` by `SourceFile` (1493), `Block` (9) or `ModuleBlock` (1). Both method containers are now on the predicate and all three function containers already were, so no recorded callable in this tree has an innermost recording scope the predicate does not open. ### The new pins can fail — a fifth ablation, and the earlier four re-read All through `scripts/ablation-replace.mjs`: anchor count plus blob hash prove each mutation landed, blob-equals-HEAD plus an empty `git diff HEAD` prove each restore. Baseline `node scripts/check-tenant-audit-census.mjs --self-test` exits 0 at **81 of 81** cases (79 + the two new ones) plus the check's own 27. | ablation | mutation | self-test result | |:--|:--|:--| | A4 (new) | ` \|\| ts.isObjectLiteralExpression(node)` deleted | **exactly 2 of 81 red** — both new pins, each `got other/platform-type`, the pre-fix answer; nothing else moves | | A0 | the lookup reverted to `lookup(fname, callee)` | 3 of 81 — unchanged reach | | A1 | the `this.` arm returns the floor | 1 of 81 — unchanged reach | | A2 | `opensClassScope` opens nothing | 6 of 81 — unchanged reach | | A3 | `opensCallableScope` opens nothing | **5 of 81 — moved from 4** | A3's extra red is the new reversed-order pin, and it is composition rather than breakage: with `opensCallableScope` returning false nothing is recorded under any scope, every name is answered by the FLOOR, and in that declaration order the floor holds the `Map`. No ⛔ FLOOR-labelled pin reds under A2 or A3. **This supersedes the `3 / 1 / 6 / 4 of 79` reading above, which describes the tree at `a36551a559`.** ### The published population, re-measured — still zero, with both controls Three instruments over **one tree** via `runCensus({ root })` (2026-09-22T23:15:58Z at the `9a24005710` tree): the `origin/main` `c120dbdfb8` module, the pre-fix `a36551a559` module and this head's module each produce **112739 bytes, byte-identical** — at **227 write call sites, 153 decidable, 74 undecidable, 576 sources**, 63 engine types, 144 non-engine subtractions, `platform-type` 3, 0 unresolved, 0 unledgered. ⚠️ **A hash of that JSON identifies neither a tree nor an instrument on its own**, so the reading above is stated with the tree AND the day it was taken: `measuredAt()` returns `{ date: new Date().toISOString().slice(0,10), ref }` and `runCensus` carries BOTH, so the same tree and the same three instruments hash differently on two different days. Measured: the `9a24005710` tree hashes `e0f2e86741e4…` dated 2026-09-22; the `b73fd40aa0` tree hashes `075734a25f19…` dated 2026-09-22 and `c18ad4dc836a…` dated 2026-09-23, and masking `measuredAt.date` alone makes those two byte-identical. The DRIFT gate is unaffected — it masks the unenforced corpus-scale block out of its comparison on purpose. - **FIRING** — add one tracked corpus file carrying the E7 shape with `IDataEngine` and the comparison MOVES in the predicted direction: `origin/main` reads 228 sites / 154 decidable / `platform-type` 3 and PLACES the write; the pre-fix head reads 227 / 153 / 145 subtractions / `platform-type` 4 and SUBTRACTS it under the defended arm; this head reads 228 / 154 / `platform-type` 3, byte-identical to `origin/main`. The diff names the site. - **DARK** — the same added file WITHOUT the object literal is byte-identical on all three instruments at 228 sites, so the movement is attributable to the shape and not to the added file. The probe removed, `git status --porcelain` is empty and the comparison returns to the byte-identical 227. **No real call site moves**: 0 of 227 change classification in either direction. ### The prose the shape falsified Both docblocks and the self-test success line claimed a property E7 disproved. `opensCallableScope`'s now names the object literal as a member-name container beside the class and says why omitting it cost the same site; `calleeScopeChain`'s `f()` bullet records that excluding a container from the lookup chain is only half of the property, the other half being that the recording predicate put every member name under its container; and the success line states the pinned property in both orders for both containers. ### The two re-stamped artefacts `node scripts/tenant-audit-census.mjs --write` at the instrument commit rewrites **two lines in total** — the provenance stamp in each artefact, `bfd92001b` → `496d65dc7`. Every ENFORCED figure is byte-identical, because the population did not move. `node scripts/check-tenant-audit-census.mjs` exits **0**: "227 write call sites certified (153 decidable; 9 tenancy-enabled sites PROVABLY carry no tenant context, 34 more unreadable), 23 prose figures held to the census". Neither artefact was hand-edited. ### Verification, re-derived at `b73fd40aa0` `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` from a worktree at this head derives **63 families** from the same 3 changed paths (294 changed lines, under the 5000 human-merge threshold). All 63 ran with each exit code captured BEFORE any pipe and reconciled by `--ran`: **63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN, none exited 3.** Five first refused on an unbuilt worktree and named what they needed — `check:doc-formula-expressions`, `check:doc-security-posture`, `check:docs-transcript-drift` (built `@objectstack/lint`), spec `check:docs` (built spec / `gen:schema`), spec `check:skill-examples` (built `@objectstack/client` and `@objectstack/client-react`) — and all five are green after the two builds, both run through `scripts/pm/os-verify-lock.sh` (VERDICT `command-exit 0`, waited 0s). That zero has controls on the reconciler itself: handed the PRE-BUILD record the same tool reads "63 derived, 59 run, **4 NOT-MEASURED**"; handed a record with one family omitted it reads "**1 of 63 derived families UNRUN**" and exits 1. `pnpm lint` — the repo-wide `eslint . --no-inline-config`, CI's own scope and not a narrowing — exits **0** at `b73fd40aa0`. The edited file is a tool script, so its own suite is owed beyond the derived set: no `*.test.ts` in the repository names `tenant-audit-census`, and the module's only importer is `scripts/check-tenant-audit-census.mjs`, which is in the derived set and green. The sibling `scripts/platform-object-tenancy-census.mjs` carries no scope machinery at all, so this shape does not exist there. ### The `this`-container repair — `this` is not "the enclosing class" Commits `8f8828bf66` and `93555d0fcd`, answering the at-tier contract review's blocking finding F1. The object-literal repair put every member name under its container on the RECORDING side. The `this.m()` LOOKUP still read "the chain of enclosing classes": `calleeScopeChain`'s `this.` arm returned `opensClassScope`, and both `thisProps.lookup` sites defaulted to the same predicate. The language never resolves `this` that way. `this` inside an object literal's own method is the LITERAL, whatever classes enclose the literal; inside a `function` expression it is whatever the call supplies; an arrow has none of its own. Measured, reading a `this.getEngine()` written in an object-literal method through the enclosing class SUBTRACTED a real engine write `origin/main` had PLACED — under the DEFENDED `platform-type` arm, which prints nothing and is counted nowhere, the direction this census's header names as the one it cannot survive. `thisContainerOf(site)` walks the parents: an arrow is TRANSPARENT; a `function` expression, a `function` declaration and the source file return `null`, which is the file-wide FLOOR alone; and a method, accessor, constructor, property declaration or class static block STOPS the walk and hands back ITS OWN parent — a class for a class member, the OBJECT LITERAL for a literal's method. ⛔ A plain `key: value` property assignment is not a stop, so `{ k: this.engine }` written in a class method still reads the class. The `this.` arm and both `thisProps.lookup` sites now pass a chain reaching EXACTLY that container, and a miss inside it falls to the FLOOR — never outwards to a class that merely encloses the site. ### The movement, measured on three trees Receiver verdicts for the same synthetic sources, through the census's own exported `declaredTypesIn` / `resolveReceiver` / `nonEngineReason`, taken from three module versions in one run. `IProbeEngine` is in the engine type index; the other type is a Map keyed by string (`MAP_OF_STRING_TO_NUMBER`, spelled this way to keep the generic's angle brackets out of a GitHub body). `tsc` is `--noEmit --strict --ignoreConfig` on 6.0.3, the ORACLE: exit 0 means the compiler resolved `insert` on the receiver. | probe | shape | `origin/main` `544767da3b` | pre-fix head `b73fd40aa0` | this head `93555d0fcd` | `tsc` | |:--|:--|:--|:--|:--|:--| | R1 | `this.getEngine()` in an object-literal method nested in a class member; the literal's method declared first | **engine** | other/platform-type | **engine** | 0 | | R1r | the same, the class's method declared first | other/platform-type | other/platform-type | **engine** | 0 | | R2 | the literal's method is the file's ONLY `getEngine` (floor) | engine | engine | engine | 0 | | R8 | the same literal at FILE level, no enclosing class at all | other/platform-type | other/platform-type | **engine** | 0 | | R11 | an ARROW inside the literal's method | engine | other/platform-type | **engine** | 0 | | R13 | a GETTER on the literal | engine | other/platform-type | **engine** | 0 | | R6 | `this.engine` inside a `function` EXPRESSION in a class method | engine | other/platform-type | **engine** (main's answer) | 2 `TS2683` | | R7b | an inner anonymous CLASS EXPRESSION whose `this` lacks the name, the floor holding the Map | other/platform-type | engine | **other/platform-type** (main's answer) | 2 `TS2339` | | R3 | `this.getEngine()` straight in a class method, beside a same-named file-level function | engine | other/platform-type | other/platform-type | 2 `TS2339` | | R12 | a class declared INSIDE an object-literal method answers its own `this.getEngine()` | engine | other/platform-type | other/platform-type | 2 `TS2339` | | R10 | `{ k: this.engine }` in a class method, two classes | engine, engine | engine, platform-type | engine, platform-type | 0 | | R9 | DARK: the literal's member under a DIFFERENT name | other/platform-type | other/platform-type | other/platform-type | 2 `TS2339` | R1 is F1 exactly as the review stated it, and R1r is the same shape in the order where `origin/main` was wrong too. Controls on the same instrument and run, all UNCHANGED between the pre-fix head and this one: `this.PROP` in a class method and the same inside an ARROW read `engine, platform-type` in both; R9 is the dark control (the fix is name-specific); R3 and R12 are the class half this PR already landed, and the repair leaves both standing, agreeing with the compiler. **R7b's movement is acceptable, and it is the direction the repair must take — not a regression.** Three readings, all on the same instrument and run. ① The shape does not compile: `tsc` answers `TS2339: Property 'getEngine' does not exist`, because an inner class expression does not inherit the lexically-enclosing class's members through `this`. A tracked corpus that type-checks cannot contain it, and the corpus scan below reads 0 either way. ② The pre-fix head's answer is the LANGUAGE ERROR, not a capability being lost: walking outwards to the enclosing class invents a resolution TypeScript does not have, and the repair declines to invent it. ③ The new answer IS `origin/main`'s answer, so this PR's own contract — 「The repair may not cost a single site that resolved before」 — holds exactly. A regression here would be a site moving from RESOLVED to unresolved relative to `origin/main`; this one moves from an invented resolution back to main's. It is pinned in both declaration orders rather than left to prose. ### The new pins can fail — eight ablations, each proven on disk All through `scripts/ablation-replace.mjs`: anchor count plus blob hash prove each mutation landed, blob-equals-HEAD plus an empty `git diff HEAD` prove each restore, and `git diff HEAD --name-only` was asserted empty after every leg. There is no build leg to reach — the module is executed from source. Baseline `node scripts/check-tenant-audit-census.mjs --self-test` exits 0 at **93 of 93** cases (81 + 12 new) plus the check's own 27. | ablation | mutation | at `b73fd40aa0` | at this head | |:--|:--|:--|:--| | A0 | the callable lookup reverted to `lookup(fname, callee)` | 3 of 81 | **5 of 93** | | A1 | the `this.` arm returns the floor | 1 of 81 | **5 of 93** | | A2 | `opensClassScope` opens nothing | 6 of 81 | **8 of 93** | | A3 | `opensCallableScope` opens nothing | 5 of 81 | **9 of 93** | | A4 | the object literal removed from `opensCallableScope` | 2 of 81 | **5 of 93** | | A5 (new) | the `this.` arm reverted to `opensClassScope` — the pre-fix spelling | — | **exactly 6 of 93** | | A6 (new) | `thisProps.lookup` reverted to its default chain | — | **1 of 93** | | A7 (new) | `thisContainerOf` stops at an arrow instead of passing through it | — | **1 of 93** | A5 is the direct ablation of this repair and every one of its six reds carries the pre-fix `got` string. Each earlier count grew, and that is composition rather than breakage: the new pins read the chain `calleeScopeChain` hands back, so neutralising a predicate it depends on now breaks them too. **This supersedes the `3 / 1 / 6 / 5 / 2 of 81` reading above, which describes the tree at `b73fd40aa0`.** No ⛔ FLOOR-labelled pin reds under A2 or A3; two of them DO red under A0 and A5, so the floor pins are not vacuously true either. ### The published population, re-measured — still zero, with three controls Three instruments over **one tree** via `runCensus({ root })` — ⚠️ a tree whose HEAD was the fix commit `8f8828bf66`, ⛔ not the re-stamp `93555d0fcd`, as the at-tier review measured from the JSON's own `measuredAt.ref`: the `origin/main` `544767da3b` module (byte-identical to `c120dbdfb8` for this file and `ts-parse.mjs`), the pre-fix `b73fd40aa0` module, and this head's. All three produce **112739 bytes, `sha256 4c35ebc1b26c876b48fa91e9e53d044ea4a159fd049624dca14ef5520abdb8cb`** at that tree (the same bytes at `93555d0fcd` on 2026-09-23 hash `71eb74aa2070af3c…`, differing only in `measuredAt.ref` — acceptance note 6's hazard, this time in the `ref` field) — byte-identical — at **227 write call sites, 153 decidable, 74 undecidable, 576 sources**, 63 engine types, 144 non-engine subtractions, `platform-type` 3, 0 unresolved, 0 unledgered. **0 of 227 change classification, in either direction.** - **FIRING** — add one tracked corpus file carrying the R1 shape with `IDataEngine` and the comparison MOVES exactly as the review predicted: `origin/main` reads **228 sites / 154 decidable / `platform-type` 3** and PLACES the site with `placedBy: declared-type`; the pre-fix head reads **227 / 153 / 145 subtractions / `platform-type` 4** and the site is GONE; this head reads **228 / 154 / `platform-type` 3**, byte-identical to `origin/main`. The `--json` names the file and the line. - **DARK** — the same added file with its `this` rooted on a CLASS MEMBER instead of a literal's method is byte-identical on all three instruments at 228 sites, so the movement is attributable to the shape and not to the added file. Probe removed, `git status --porcelain` is empty and the comparison returns to the byte-identical 227. - **SCANNER** — over all 576 sources, the write call sites carrying a `this`-rooted receiver number **184 by the round's own scanner** — ⚠️ not in the tree, and the at-tier review could not reproduce the 184 under four definitions (it reads **186**, or 182 counting raw text starting `this.`), so treat the total as definition-dependent, and **0** of them sit where the container their `this` names is not the nearest enclosing class. That zero is the whole exposure of this repair. Firing control with the probe tracked: **+1 site, 1 exposed**, naming the file; dark control, the same file with a class-member `this`: **+1 site, 0 exposed** — the review reproduced both at 187 / 1 and 187 / 0. ⭐ The load-bearing readings are the **0** and the **+1**, and both reproduce under every definition. ### The prose the shape falsified `opensClassScope`'s docblock said "the chain for a property is the chain of enclosing classes"; it now says it is a RECORDING predicate and names `thisContainerOf` as the lookup-side one, with the two shapes that break the old claim. `calleeScopeChain`'s `this.m()` bullet no longer says "the enclosing class". The existing pin named 「a `this.METHOD()` call DOES read the enclosing class」 now reads 「…the class its `this` names」 — the same case, the claim narrowed to what it actually holds. The self-test success line states the pinned property for both containers, through an arrow, inside a `function` expression, and for a plain `key: value`. ### The two re-stamped artefacts `node scripts/tenant-audit-census.mjs --write` at the instrument commit rewrites **two lines in total** — the provenance stamp in each artefact, `496d65dc7` → `8f8828bf6`. Every ENFORCED figure is byte-identical, because the population did not move. `node scripts/check-tenant-audit-census.mjs` exits **0**: "227 write call sites certified (153 decidable; 9 tenancy-enabled sites PROVABLY carry no tenant context, 34 more unreadable), 23 prose figures held to the census". Neither artefact was hand-edited. ### Verification, re-derived at `93555d0fcd` `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` from a worktree at this head derives **63 families** from the same 3 changed paths (446 changed lines, under the 5000 human-merge threshold). All 63 ran with each exit code captured BEFORE any pipe and reconciled by `--ran`: **63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN, none exited 3.** Five first refused on an unbuilt worktree and named what they needed — `check:doc-formula-expressions`, `check:doc-security-posture` and `check:docs-transcript-drift` (built `@objectstack/lint`), spec `check:docs` and spec `check:skill-examples` (built `@objectstack/spec`, then `@objectstack/client` and `@objectstack/client-react`) — and all five are green after the two builds, both run through `scripts/pm/os-verify-lock.sh` (`VERDICT command-exit 0`; waited 64s and 229s). That zero has controls on the reconciler itself: handed the PRE-BUILD record the same tool reads "63 derived, 59 run, **4 NOT-MEASURED**"; handed a record with one family omitted it reads "**1 of 63 derived famil(ies) UNRUN**" and exits 1. `pnpm lint` — the repo-wide `eslint . --no-inline-config`, CI's own scope and not a narrowing — exits **0** at `93555d0fcd`. The edited file is a tool script, so its own suite is owed beyond the derived set: no `*.test.ts` in the repository names `tenant-audit-census`, and `git grep "from '.*tenant-audit-census.mjs'"` returns exactly one hit, `scripts/check-tenant-audit-census.mjs`, which is in the derived set and green. ## Acceptance notes Eight things were found and deliberately NOT fixed here. **1. A class's member types are not indexed, so `x.method()` still conflates across two classes.** `declaredTypesIn`'s `shapes` map indexes interfaces and type-literal aliases only, never `class` declarations. Two classes each declaring a method `getEngine`, called from outside both, answer `engine/IProbeEngine | engine/IProbeEngine` — a `Map.delete` admitted. CONTROL: the identical member types declared as INTERFACES are told apart correctly (`engine/IProbeEngine | other/platform-type`), so the instrument can discriminate; what it cannot do is read a class's members. CONTROL: a lone class member is still correctly subtracted. Measured to PRE-EXIST this change and be untouched by it: under the callable-scope ablation the same probe answers identically. Different mechanism from this card's keying, so a separate card rather than a rider. **Re-measured on this head, and the pin's agreement with the compiler is order-dependent.** With `A` declared first the census answers `engine/IProbeEngine`, which is also what `tsc --noEmit --strict` answers (exit 0). With the two classes in the other order the same source answers `other/platform-type`, while `tsc` still answers the engine (exit 0). So the file-wide FLOOR reaches the right verdict in the pinned case by its first-typed-wins rule and not by reading `A`'s members — which is exactly the card this note names. Measured to be untouched by the callee-shape repair: the pre-fix head answers `other/platform-type` in BOTH orders, so the repair moved the first order and left the second where it was. **2. `classifyHttp` reads an ABSENT rate-limit header as zero remaining**, in `scripts/pm/label-write.mjs`. The guard is `(status === 403 || status === 429) && Number(rateRemaining) === 0`, and `Number(null)` is `0`, so a 403 carrying no `x-ratelimit-remaining` header at all classifies as `ratelimit`. Through `stopSignalFrom` that verdict writes a 30-minute STOP MARKER refusing EVERY write by that token key, whatever path failed. Observed live in this container: `scripts/pm/with-fleet.sh --read` took the egress proxy's 403 on the App-token mint path and wrote `STOP MARKER written for token key f665bb8f6447 until 2026-09-22T21:39Z`. CONTROLS: with the header present at 4999 the same status classifies `refusal` and `stopSignalFrom` returns null; with it genuinely 0 the intended `ratelimit` arm still fires; a 404 near miss classifies `refusal`. The producer is honest — `fleet-token.mjs`'s `rateRemainingOf` documents `null` as "absent" — and the consumer misreads it. **3. A known boundary, not a defect.** The file-wide FLOOR is kept on all three maps, so a `this.PROP` no enclosing class declares, and a method called from outside its class, still resolve file-wide. Removing the floor would LOSE sites, which is the direction this census must never fail in, so it is pinned in both new FLOOR cases rather than closed. **4. Acceptance note 2 was not re-measured this round.** The `classifyHttp` reading above was taken in an earlier round of this branch and is recorded here unchanged; this round neither reproduced nor refuted it, and its own card carries it. **5. Two self-test cases share one name verbatim.** `⭐⭐ …and in the other declaration order, the real engine write is no longer subtracted as a language global` names both a two-parameters case and a two-classes case; ablation A2 reds both and prints the same line twice, so a reader cannot tell which pin failed. Pre-existing — both predate this round. This round hit it by writing a third collision of its own and removed that one (`496d65dc79`); the pre-existing pair is left alone as out of scope. Its own card or the next PR in this file, not a rider. **6. A census `--json` hash names a DAY as well as a tree.** `measuredAt()` returns `{ date: new Date().toISOString().slice(0,10), ref }` and `runCensus` carries both, so a `sha256` over `--json` changes at midnight UTC on a tree nobody touched. Measured: the `b73fd40aa0` tree hashes `075734a25f19…` dated 2026-09-22 and `c18ad4dc836a…` dated 2026-09-23, identical apart from that one field. Not a defect in the census — the date is deliberate (「tell a reader how old the unenforced numbers are」) and the DRIFT gate already masks the whole unenforced block out of its comparison — but a prose-citation hazard, and it has now produced one wrong hash in this body. The ad-hoc three-instrument byte comparison these rounds use is the thing that should mask `measuredAt.date`, never the artefact. Carrier: the next PR to touch `scripts/tenant-audit-census.mjs`. Noted, not filed. **7. `thisContainerOf` treats every `function` expression as naming no container, and one compiling shape disagrees.** TypeScript contextually types `this` inside a `function` expression that is an object literal's `key: value` as that literal. The at-tier review's probe X1 (`const o = { getEngine(): IProbeEngine …, z: function () { this.getEngine().insert(…) } }` inside a class whose own `getEngine` returns a Map) compiles (`tsc --noEmit --strict` exit 0) while this head reads the floor and subtracts the write. ⛔ **Not a cost of this PR** — `origin/main` and the pre-fix head answer identically, and the corpus exposure is **0** (firing control 1 with the probe tracked). A one-clause repair (a `FunctionExpression` whose parent is a `PropertyAssignment` in an `ObjectLiteralExpression` names that literal) plus a pin in both orders. Carrier: the next PR to touch `scripts/tenant-audit-census.mjs`. Noted, not filed. **8. The R7b ruling's third argument carries it; the first two over-generalise.** An inner class expression that `extends` the outer class inherits the member and **does** compile (probe R7x); there the pre-fix head placed the write, and `origin/main` and this head both subtract it. ⇒ 「walking outwards invents a resolution TypeScript does not have」 is false for the inheriting case — the pre-fix answer was right by the accident of lexical enclosure, and the census models no inheritance at all (pre-existing, identical at `origin/main`). The same family: a `function (this: C)` annotation. ⛔ Neither is a cost against `origin/main` — ③, 「the new answer is `origin/main`'s」, is the load-bearing leg and it stands. Exposure **0** in both (firing control 1 each). Carrier: the same next PR. Noted, not filed. --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr --- _Generated by [Claude Code](https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr)_ --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ed4b655 commit e99a14c

3 files changed

Lines changed: 417 additions & 29 deletions

File tree

‎content/docs/permissions/tenant-audit-census.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -296,7 +296,7 @@ holds still. They are required to be HERE and to say WHEN they were true;
296296
their values are not compared. The reasoning, and the measurement behind it,
297297
are in `scripts/check-tenant-audit-census.mjs`.
298298

299-
Measured on 2026-09-22 at `9db2c6234`.
299+
Measured on 2026-09-23 at `8f8828bf6`.
300300

301301
| corpus scale (not enforced) | count |
302302
| :--- | ---: |

‎docs/audits/2026-08-tenant-audit-write-call-sites.counts.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ holds still. They are required to be HERE and to say WHEN they were true;
9090
their values are not compared. The reasoning, and the measurement behind it,
9191
are in `scripts/check-tenant-audit-census.mjs`.
9292

93-
Measured on 2026-09-22 at `9db2c6234`.
93+
Measured on 2026-09-23 at `8f8828bf6`.
9494

9595
| corpus scale (not enforced) | count |
9696
| :--- | ---: |

0 commit comments

Comments
 (0)