Repository navigation
Commit e99a14c
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). 1 parent ed4b655 commit e99a14c
3 files changed
Lines changed: 417 additions & 29 deletions
File tree
- content/docs/permissions
- docs/audits
- scripts
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
296 | 296 | | |
297 | 297 | | |
298 | 298 | | |
299 | | - | |
| 299 | + | |
300 | 300 | | |
301 | 301 | | |
302 | 302 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
90 | 90 | | |
91 | 91 | | |
92 | 92 | | |
93 | | - | |
| 93 | + | |
94 | 94 | | |
95 | 95 | | |
96 | 96 | | |
| |||
0 commit comments