Repository navigation
fix(scripts): tenant-audit census stores declared type text as written, and resolves names by scope - #19650
Conversation
… by scope Route ① for the census's unreadable-type-text class: the declared type text is stored exactly as the source spells it, so a type literal that separates its members by a newline alone -- legal TypeScript -- still re-parses as the synthetic alias the door rule is read off. The collapse stays at the presentation boundary, where it already lived. `declaredTypesIn` resolves a name by its innermost enclosing scope, with the old file-wide map kept as a floor so no receiver that resolved before stops resolving. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr
… in all three directions The NEWLINE pin flips from a per-site refusal to a placement -- same rule, same text, the separator now survives storage -- and two further storage sites (a parameter annotation, an interface member) are pinned beside it. The scope battery pins what the bare-name map actually cost: a `Map.delete` scored an engine write, a real engine write subtracted as a language global, and the object name and elevation read from the wrong scope. The file-wide floor is pinned too, so nothing that resolved before stops. Three UNTYPED_RECEIVERS rows go: the census can now read those receivers' declared types, so the rows had outlived the code they described. One arrives: the better-auth adapter the SCIM transaction binds, which the bare-name map had been answering with an unrelated `Record` 2000 lines away. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr
…he corpus The arm survives -- a genuinely unparseable derived text is still refused -- but "stored whitespace-collapsed" is no longer true of it, in the header, in the reason string, in the refusal and in the block both artefacts carry. What it means now is that this module could not read back a text it derived, which is a defect to file against the census rather than a restyle to ask of legal TypeScript. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr
…he repaired reading `node scripts/tenant-audit-census.mjs --write`, plus the four hand-written prose numbers the gate names and `--write` does not touch. The population is unchanged at 227 sites. What moves is attribution: six sites stop needing an `UNTYPED_RECEIVERS` row and are read by their declared type instead, six move the other way onto the object-name rescue, and `bootstrap-declared-webhooks.ts` resolves its object name to `sys_webhook` for the first time -- undecidable becomes enabled, which is where the two extra tenancy-enabled sites come from. `migrate-webhook-secrets.ts` stays undecidable in the same table, because there the name really is a bare typed parameter. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr
Text only, no behaviour: the gate's refusal no longer tells an author to spell the literal's members with `;` separators, because the stored text is no longer collapsed and nothing at the site is wrong. It now says what the generator's copy says -- this tool could not read back a text it derived, which is a report to file against the census. `ts-parse.mjs`'s fourth-door section described that collapse in the present tense. Past-tensed, with the reason the door is NOT retired alongside it: the door is about text a process synthesised, never about one cause of it. The dated parser measurement stays -- it is a fact about TypeScript. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr
…nsus-type-text-roundtrip
CI red is THIS PR's, declared in advance, and the repair is in flight — 2026-09-22T04:28ZSeat note from What is failing.
Error classes, all of one family: 2 × Why it is not a surprise. This PR's defect-2 repair moves the census totals, which stales the two committed artefacts. Both sit outside the file surface this round was dispatched with, so the dev stopped at the fence and declared the red rather than widening silently. That was the instructed behaviour and it is graded as correct. The seat has since widened the fence — the artefacts are regenerated on this branch, not in a follow-up card.
⇒ 114 of 185 declared gates are NOT MEASURED on this head, ⛔ not passed. The seat will not treat this PR as green on anything but a head where that tail actually ran. ⭐ One reading worth recording, because it is evidence for another cardTwo of the six failing self-test cases are, verbatim:
Those are controls — cases that exist to assert a stale number is not a finding. They fail for exactly the reason they were written to deny, because the gate's self-test reads the live tree through ⇒ this is CI independently confirming #19299 rather than the dev's report asserting it, and it is why that card has been re-priced UP ( ⛔ No re-run requested: 「flake」 is not in question here, the cause is established, and the fix is a push away. Generated by Claude Code |
…able names (objectstack-ai#19749) Fixes objectstack-ai#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 objectstack-ai#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 objectstack-ai#19300 landed (PR objectstack-ai#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 objectstack-ai#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>
Fixes #19300
Two defects in one file's TypeScript reading, with two independent fixes. Route ① for
the first, as triage on the predecessor card named it; scope-keyed name resolution for
the second.
Defect 1 — the census collapsed declared type text before re-parsing it
declaredTypesInstored a declared type throughgetText(sf).replace(/\s+/g, ' '). Thedoor rule is later read off that text by re-parsing it as a synthetic alias
(
type CensusReceiver = THE_STORED_TEXT;). A type literal may separate its members bya NEWLINE alone — legal TypeScript — and the collapse turns that separator into nothing,
so the synthesis does not parse and the site is refused as
type-text-not-round-trippable. The author was told to restyle legal TypeScript to workaround the tool's own re-serialisation.
The text is now stored exactly as the source spells it. The collapse was never needed at
the storage boundary: every reader of that text either scans it for identifiers or
re-parses it, and the one place a single line is genuinely required — the artefacts —
already collapses at the point of render (
runCensus,cell()).The real site set is TWO, not the three the card names. Measured on this branch:
note()'stypefieldreadTypeTextDoorthroughfromEntryshapesmember typememberTypeOfShapesreceiverKeynote()'sinitfieldaspath'sr.type.getText(sf):355inmemberIsEngineDoorreplace(/\s+/g, '')— an empty replacement, and its only consumer ist !== 'string'. It normalises for an equality test and is never handed to a parser.Defect 2 —
localswas keyed by bare identifier, with no scopeA file may contain several
engines. Keyed on the identifier alone they were one entry,and the first TYPED one decided for all of them. That is three failures, not one, and
which one you get depends on declaration order:
enginedeclared first,MapsecondMap.deletescored an engine write and admitted to the certified populationMapfirst, engine-typed secondplatform-type— a DEFENDED arm, so it prints nothing and is counted nowhereThe middle row is the expensive one and the card does not name it: the census's own header
calls a silent subtraction the direction it cannot survive being wrong in, and the
bare-name map was producing them.
The conflation also decided two other verdicts the artefacts carry — which object a
site writes (
resolveObjectNameArg) and whether it is elevated (elevationOf). Bothare pinned now.
declaredTypesInresolves a name through its innermost enclosing scope, with the oldfile-wide map kept as an explicit floor tier: consulted only when no enclosing scope
declares the name, so no receiver that resolved before stops resolving.
What this moves on the real corpus
The population is unchanged — 227 sites before, 227 after, none added, none removed.
Fourteen sites change attribution, and one receiver becomes honestly unreadable:
UNTYPED_RECEIVERSrow (the census can now read their declaredtype), so three ledger rows are deleted as stale.
bootstrap-declared-webhooks.tsresolve their object name tosys_webhookfor the first time —
undecidablebecomesenabled.metadata-store.tsmove fromdeclared-typeto the object-name rescue.Their receiver is a contextually typed arrow parameter (
(engine) => engine.insert(…)against a declared
run: (engine: IDataEngine) => …), which needs a type checker thismodule does not have. The bare-name map had been answering it from an unrelated
declaration and happened to be right; the verdict is unchanged either way.
const db = context.databaseinauth-manager.ts— was being answered by aa
Recordkeyed by string declared 2000 lines away and silently subtracted asplatform-type. It is the better-auth adapter bound to the SCIM transaction, and itarrives in
UNTYPED_RECEIVERSsaying so.✅ Closed: the artefacts are regenerated in this PR
This PR's originally declared file surface was
scripts/tenant-audit-census.mjsplus itsself-test and fixtures, and the defect-2 repair moves the census totals, which stales the
committed artefacts. The dev stopped at that fence and declared the red rather than
widening silently. The seat then widened the fence — the artefacts ride the source PR
rather than a follow-up card, per the lane rule 「其重生成的 references 产物归源 PR」.
Both steps are done:
node scripts/tenant-audit-census.mjs --writeregeneratedcontent/docs/permissions/tenant-audit-census.mdxanddocs/audits/2026-08-tenant-audit-write-call-sites.counts.md.--writedoes not touch were taken from the gate's ownoutput, not retyped from any summary:
const41→43, unreadable-and-tenancy-enabled32→34 (twice), statically decidable 151→153, undecidable 76→74.
check-tenant-audit-census.mjsand its--self-testare now green (27/27, up from 6failing), which also confirms the diagnosis: all six failures were the drift.
Defect 1 is artefact-neutral and was measured as such: with only the two storage-site
lines changed, the census
--jsonis byte-identical to the base tree (diffexit 0 overthe full document). Every count that moves comes from defect 2.
Verification
All 66 gates
scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsderives for the widened diff were run and reconciled with
--ranon the final head:66 derived / 66 run / 0 NOT MEASURED / 0 UNRUN, every family carrying a recorded exit
code, all zero. The set grew from 29 because the diff now reaches
content/docs/**,docs/audits/**andts-parse.mjs.@objectstack/lintbuilt, one
packages/spec/json-schema, one@objectstack/client-reactdeclarations).They were recorded as NOT MEASURED, never as clean, and re-measured to exit 0 after
two builds through
scripts/pm/os-verify-lock.sh.Reverse verification — three ablations through
scripts/ablation-replace.mjs, each runfrom the committed state with the on-disk mutation proven and the restore verified
blob == HEADwithgit diff HEADempty:note()'stypeshapesmember typeThe self-test grows 57 to 67 cases. The
type-text-not-round-trippablearm is kept andstill exercised: a genuinely unparseable text is still refused and still places nothing.
What changed is what it means — it is now the alarm for a defect in this module's own
re-serialisation, never an instruction to restyle the corpus, and the refusal text says so.
Acceptance notes
scripts/check-tenant-audit-census.mjs's refusal prescription ("spell theliteral's members with
;separators so the stored text round-trips") was made false bythis change and is the copy CI prints. Fixed in this PR — text only. It no longer
asks the author to restyle anything; it says the fault is the census's own and to file it
there.
scripts/ts-parse.mjs's header documented the collapse as a live defect. Fixedin this PR — text only, now historical. ⛔ It deliberately does NOT retire the door it
documents: the measurement is a fact about the PARSER, which has not changed, and any
caller re-parsing text it synthesised itself can fail the same way.
in one file sharing a property name:
class B'sthis.engine.delete('k')on aa
Mapof string to number scoresengine/IProbeEngine— aMapadmitted to the certifiedtenancy population.
fnReturnsbehaves the same way for two same-named local functions.thisPropsneeds CLASS scope rather than lexical scope, which is a different notion fromthe one this PR adds, so it is reported rather than folded in. Filed by the seat as
[finding] tenant-audit-census keys thisProps and fnReturns by bare identifier too — two classes sharing a property name conflate, and a Map is ADMITTED to the certified tenancy population (the direction the census header says it cannot survive being wrong in) #19652.
the
metadata-store.tsnote above. A capability gap, not a regression; recorded so the6 sites' move to the object-name rescue is not read as one.
refuse#19299 acutely live. That card is the census self-test'sclean-census control being coupled to the live tree through
refuse. The coupling waslatent while the tree was clean; with the artefacts drifted, 6 of the gate's 27
self-test cases now fail, and 5 of them are controls asserting that a stale number is
NOT a finding. They fail for the reason they exist to deny. ⛔ Not folded in — it is a
different defect and deliberately serialised behind this card.
Generated by Claude Code