Skip to content

fix(census): scope the tenant-audit census on class-property and callable names - #19749

Merged
os-support-ai merged 11 commits into
mainfrom
claude/issue-19652-census-class-scope-keys
Sep 23, 2026
Merged

os-support-ai merged 11 commits into
mainfrom
claude/issue-19652-census-class-scope-keys

Conversation

@os-support-ai

@os-support-ai os-support-ai commented Sep 22, 2026 •

Copy link
Copy Markdown
Collaborator

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:

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.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr


Generated by Claude Code


Generated by Claude Code

…ble-name maps

`thisProps` and `fnReturns` were keyed on the bare identifier, so two classes in
one file sharing a property name were ONE entry and the first TYPED one decided
for both. Measured on a synthetic pair, that admits a `Map.delete` to the
certified tenancy population in one declaration order and SUBTRACTS a real
engine write as `platform-type` in the other -- the silent direction the
census's own header names as the one it cannot survive being wrong in.

A class property is not a lexical name: `this.engine` in class B is B's. A
callable's name belongs to whatever declares it -- a class body for a method,
the enclosing block or file for a function. So `scopeChainOf` and `scopedNames`
take the scope predicate, and all three name maps are scoped ones under the
predicate that matches how the language scopes that kind of name. Each keeps
the file-wide FLOOR, so 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.

Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr
Co-authored-by: Claude <noreply@anthropic.com>
…f-test

Eight cases in the module's own self-test, in both declaration orders for each
map, at both storage sites a class property reaches its type through, and
through the `this.<base>.<member>` base. Two FLOOR cases hold the other
direction: a `this.<prop>` no enclosing class declares, and a method called from
outside its class, both still resolve file-wide, so nothing that resolved before
this change stops resolving.

Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr
Co-authored-by: Claude <noreply@anthropic.com>
…ange

`node scripts/tenant-audit-census.mjs --write`, the repo's own tooling, on the
merged tree. Every ENFORCED figure is byte-identical -- 227 write call sites,
153 decidable, 9 tenancy-enabled sites provably carrying no tenant context and
34 more unreadable. What moved is the one line the gate deliberately does not
compare: the unenforced corpus-scale block's provenance stamp, so the page now
says the population was re-derived at a commit that carries the scoped keying.

Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr
Co-authored-by: Claude <noreply@anthropic.com>

Copy link
Copy Markdown
Collaborator Author

Served-tier: 89/89 CONTRACT_REVIEW_TIER (seat-measured, 2026-09-22T21:31Z)
Head-sha: bee92af

Contract review of objectstack-ai/objectstack PR #19749 (Fixes #19652, branch claude/issue-19652-census-class-scope-keys), head bee92afe12616174d5149cd4f2139e12a72bce98, base main = c120dbdfb81743a172622b7aec6d81bb9f2ed9bf (merge-base verified equal to origin/main at 2026-09-22T21:13:33Z). Diff: 3 files, +129/−26 (scripts/tenant-audit-census.mjs +127/−24; two artefacts one stamp line each). Rendered by an isolated at-tier subagent for seat domain:spec#1; no GitHub writes, no PR edits, nothing pushed.

Method: origin/main fetched fresh; the PR head fetched into an owned ref refs/review/pr-19749-subagent and checked out into a scratch worktree (…/scratchpad/pr-19749/wt-head), never over the shared tree (whose HEAD was already elsewhere, 1f53b0b6). All tree readings below cite the ref they were taken at. Nothing in the PR body or the seat's notes is used as evidence; every claim is re-derived.

Tier: origin/main:scripts/pm/dispatch-gates.mjs:12282 reads, verbatim, export const CONTRACT_REVIEW_TIER = 'claude-fable-5-1'; (read 2026-09-22T21:13:45Z). The served-tier control is the seat's to take from the transcript's per-request harness model stamps; this record's self-report is not admissible and is not offered. No fallback or overload notice was observed at any point in this run.

① Derived judgments

1. Is the scoping correct? — thisProps yes; fnReturns NO for a bare-identifier callee (finding F1)

Read at refs/review/pr-19749-subagent:scripts/tenant-audit-census.mjs (head:540–560 predicates, head:568–572 scopeChainOf(node, opens), head:599–635 scopedNames(opens), head:652/654 the two maps, head:813/823/854 the three consumers) and probed by running BOTH modules (origin/main's script copied uncommitted beside the head's, same sibling imports, same typescript@6.0.3 from the lockfile) on synthetic sources through declaredTypesIn → resolveReceiver → nonEngineReason, i.e. the self-test's own verdictsIn path (2026-09-22T21:22:49Z).

  • thisProps → opensClassScope (class declarations and class expressions): correct. A this.<prop> receiver walks blocks, arrows and methods transparently and stops at the innermost class, which is how this binds. Probes at head, all language-correct where origin/main was wrong: nested class (X5: Outer engine / Inner Map → head other/platform-type, main engine/IProbeEngine), this.engine inside an arrow inside a method (X6: head engine/IProbeEngine, main other/platform-type), a class EXPRESSION (const B = class {…}, X7: head right, main wrong). Dark control X9 (one class, one property): both engine/IProbeEngine. Pre-existing and unchanged boundary: this inside a function expression nested in a method is not the instance, and neither the old bare map nor the class chain knows it (not a regression).
  • fnReturns → opensCallableScope = opensScope || opensClassScope. The PR's stated reason (class-only would lose a function getEngine() declared above a class) is true and probe X8 confirms the lexical half works (a block-local const getEngine = (): Map… shadows the top-level function inside its own function and not outside it: head other/platform-type | engine/IProbeEngine, main engine/IProbeEngine | engine/IProbeEngine). But the union predicate is wrong in the other direction. The predicate governs LOOKUP (head:854 decls.fnReturns.lookup(fname, callee) for every callee shape), and a BARE-IDENTIFIER callee never resolves through a class body in the language — methods live on the prototype, not in lexical scope. Probe X1: function getEngine(): IProbeEngine {…} at file top, class C { getEngine(): Map<string, number> {…} w() { getEngine().insert('sys_user', {}, { context: { isSystem: true } }); } } — language answer engine/IProbeEngine; origin/main answers engine/IProbeEngine; head answers other/platform-type, i.e. a real engine write SUBTRACTED under the DEFENDED platform-type arm, which prints nothing and is counted nowhere — the direction the census header names as the one it cannot survive. X2 (class declared first): main and head both other/platform-type, so at head the shape is wrong in BOTH declaration orders where main was wrong in one. Probe X4: this.x.getEngine() with x: A, A.getEngine(): IProbeEngine, called inside B which has its own getEngine(): Map — language engine/IProbeEngine, main engine/IProbeEngine, head other/platform-type (the enclosing class's method wins over the receiver's). X3 (this.getEngine() inside C, method returns Map, top-level function returns engine) is the case the union predicate gets RIGHT and main got wrong. The head's docblock (head:544–553) reasons from where a callable's name is DECLARED and never from how a CALLEE is looked up, which is the site the predicate is applied at. Neither X1/X2 nor X4 is among the eight added pins. Latent today (item 4: zero corpus write-call receivers diverge), but it is a new deterministic silent-subtraction shape in the certifying instrument, unpinned.
    • Repair that follows from the reading: choose the chain by callee shape at head:854 — a bare identifier walks opensScope only; this.m() walks opensClassScope; x.m() takes the floor (or the base's member type once class members are indexed, which is the PR's own acceptance note 1) — and pin X1 in both orders plus X4.
  • locals unchanged: the diff only re-expresses noteLocal through the new note; locals.set (destructuring) is untouched at head:724.

2. Does the file-wide FLOOR preserve prior behaviour? — yes, exactly for names no enclosing scope declares

At origin/main:scripts/tenant-audit-census.mjs (read 21:15:04Z): the flat tier's insertion rule, main:583 if (!(flat.has(name) && flat.get(name).type)) flat.set(name, entry);, is the same rule as the old bare note, main:628–631 if (map.has(key) && map.get(key).type) return; map.set(key, entryOf(…)); — and both existed at origin/main (the flat tier already served locals). The head keeps that rule (head:610–615) and the visit walk is unchanged except that n is now passed (diff hunks at head:700/702/710/735), so the sequence of note calls per key is identical. lookup (head:621–627) consults the floor only when no enclosing scope under the predicate declares the name. Hence an answer changes iff an enclosing scope declares the name — precisely the shadowing case. Where the floor DOES change an answer, both changes are in the correct direction: X10 (class A typed engine: IProbeEngine first; class B declares private engine; UNTYPED and writes through it): main published engine/IProbeEngine (a placement borrowed from the wrong class); head unresolved/ — a VISIBLE refusal (the check gate's "every write call site placed" clause reddens on it). X11 (B has engine = new Map<string, number>() initializer only): main engine/IProbeEngine; head other/constructed-locally.

3. Is the bare-key spelling gone? — yes, with a firing control

grep -nE "(thisProps|fnReturns)\s*\.\s*(get|has|set)\s*\(" on the head file: 0 hits (21:15:09Z). Firing control, same grep on origin/main: 3 hits — main:765 decls.thisProps.get(r.name.text), main:775 decls.thisProps.get(r.expression.name.text)?.type, main:806 decls.fnReturns.get(fname). At head the three consumers are .lookup(name, node) (head:813, 823, 854); note is (map, declNode, key, typeNode, initializer) => map.note(declNode, …) (head:679–681) and all five recording sites pass the node (head:682, 700, 702, 710, 735); no new Map() remains for either map inside declaredTypesIn (only shapes and its per-type member map).

4. The ZERO — a reading, not an artefact

  • True before/after on the published output, not on one code path: ran origin/main's script and the head script, each --json, on the identical tree at bee92afe12 (2026-09-22T21:18:03Z). Byte-identical, 112,739 bytes each; totals writeCallSites 227, staticallyDecidableObjectName 153, tenancyEnabledProvablyNoContext 9, tenancyEnabledContextUnreadable 34; scannedSources 576; unresolved [], unledgered [], one undefendedSubtractions row (plugin-hono-server/src/adapter.ts:972 this.app / Hono, type-not-in-corpus) on both.
  • Firing control (21:23:03Z): a tracked probe packages/services/zz-review-probe/src/probe.ts carrying the card's P1 shape (class A { constructor(private readonly engine: IDataEngine) {} w() { this.engine.insert(…) } } + class B { constructor(private readonly engine: Map<string, number>) {} w() { this.engine.delete('k') } }) added to my worktree's index: main 229 sites, head 228; main placed probe.ts:7 this.engine.delete -> IDataEngine (declared-type), head did not → outputs DIFFER. Dark control: probe removed → outputs IDENTICAL (the 21:18 run). Probe removed from index and tree; worktree back to clean.
  • The lookup(name, null) equivalence the PR leans on is a property of the OLD code, not only of the new floor: lookup(name, null) returns flat.get(name) (main:598, head:628) and the flat rule main:583 ≡ the bare rule main:628–631 (item 2). So the one-code-path before/after is sound — and my output-level diff does not depend on it.
  • Shadowed declarations, re-derived with my own re-walk using the script's recording rules and the head maps' lookup(name, declNode) vs lookup(name, null) over the 576 sources (21:22:51Z): 42 NAMES (18 thisProps / 24 fnReturns) whose scoped read differs from the bare read at some declaration; 6 with conflicting declared types — the same six file:line pairs the PR tabulates. (The PR's 73 is a per-declaration count; mine is per name; the six agree exactly.)

5. The six conflicting declarations — exist, differ, none a write-call receiver

At origin/main (21:15:18–21:15:23Z): packages/services/service-messaging/src/http-outbox.ts:402 readonly code: 'DELIVERY_NOT_ELIGIBLE' vs :462 readonly code: 'RESOURCE_NOT_FOUND' | 'DELIVERY_NOT_ELIGIBLE' | 'DELIVERY_NEVER_SENT' (thisProps); packages/plugins/plugin-email/src/email-service.ts:424 send(…): Promise<TransportSendResult> vs :644 send(…): Promise<SendEmailResult>; packages/services/service-sms/src/sms-service.ts:51 Promise<SmsTransportSendResult> vs :113 Promise<SendSmsResult>; packages/services/service-settings/src/settings-service.ts:1197 get<T>(…) → Promise<ResolvedSettingValue<T>> vs :1509 get<K extends keyof T>(key: K): T[K]; packages/services/service-automation/src/engine.ts:3239 async execute(node, variables, context) (no annotation) vs :5136 Promise<AutomationResult>; packages/plugins/plugin-security/src/security-plugin.ts:2784 v is string | number vs :5790 v is string | number | bigint. None is a write-call receiver: over all 228 receivers in the head census (227 sites + 1 undefended subtraction + 0 unresolved + 0 unledgered) the spellings this.code, send(), get(), execute(), isScalarId() each match 0 (21:25:20Z; firing control: 92 receivers spelled this.<x>, 8 call-shaped); corpus grep at origin/main for (this.code|send(…)|get(…)|execute(…)|isScalarId(…)).(insert|update|delete)( under packages/services packages/plugins excluding tests → 0, with a firing control of 40 hits across 3 files for this.<prop>.<verb>(.

6. The regenerated artefacts — tool output, two stamp lines, not os-regen

node scripts/tenant-audit-census.mjs --write in the head worktree at bee92afe1 (21:21:22Z): git diff --stat = 2 files, 2 insertions, 2 deletions; the only changed lines are Measured on 2026-09-22 at \90379e0`→`bee92afe1`in each artefact — every other byte identical, so the committed pages are what the tool emits. The committed stamp names the merge commit90379e0, on whose tree the regeneration commit bee92afsits (its own diff is exactly those two stamp lines) — consistent, not a hand edit.check-tenant-audit-census.mjsrun locally exits 0 with227 … (153 decidable; 9 … 34 …), 23 prose figures held— a LOCAL re-derivation of the artefact claim, ⛔ not the gate reading; the gate isLint & Repo Gates (origin/main:.github/workflows/lint.yml:2245–2246runs--self-testthen the check), see the check-run table.git check-attr mergeat head:unspecifiedforscripts/tenant-audit-census.mjs, content/docs/permissions/tenant-audit-census.mdx, docs/audits/2026-08-tenant-audit-write-call-sites.counts.mdandscripts/check-tenant-audit-census.mjs; firing control packages/spec/spec-changes.json, content/docs/references/x.mdx, content/docs/permissions/system-context.mdx→os-regen(21:15:38Z)..gitattributesis byte-identical betweenorigin/mainand head. The merge commit90379e0's first-parent diff is 3 files from main (.changeset/19065-odata-programmatic-example-checked.md, content/docs/references/api/odata.mdx, packages/spec/src/api/odata.zod.ts`), zero overlap with the PR's paths — an ordinary merge with nothing for the regen driver to do.

7. The eight pins and the two ablations — real, and they fail for the right reason

In the head worktree (21:18:36Z), restored to an empty git diff afterwards: baseline check-tenant-audit-census.mjs --self-test → 75 census cases + 27 check cases pass. Ablation A (scopedNames(opensClassScope) → scopedNames(() => false), one line): exactly 4 red — the three ⭐⭐ class-property cases and the this.<base>.<member> case, each with the pre-fix got (engine/IProbeEngine | engine/IProbeEngine or other/platform-type | other/platform-type); both ⛔ FLOOR cases stay green. Ablation B (scopedNames(opensCallableScope) → () => false): exactly 2 red — the two same-named local-function cases; FLOOR green. So the pins can fail and fail on the predicate they belong to; they are not tautologies. Gap: no pin covers X1/X2 or X4 (item 1).

8. The clause-② declaration and the changeset

The path limb does not fire: no packages/spec/src/** path is touched (the diff is scripts/, content/docs/permissions/, docs/audits/). This review exists by the lane rule — the card #19652 carries domain:spec (labels priority:p2, pm:dispatched, domain:spec, finding, area:access, read 21:21:55Z) — origin/main:.claude/skills/pm-dispatch/SKILL.md:535 「条款②自审资格按车道:spec 与 skills 席达档席内审;未达档 ⛔ 不自审,起隔离达档子代理」 with SKILL.md:525 「强制条款②:放宽接受集或扩大公开面的卡默认判断档施工;命中即 spec 车道的活」, and dispatch-gates.mjs tierLines (12586–12590) printing the clause-② FLOOR "owed in the spec and skills lanes, in-seat at tier or by the at-tier subagent". I did not find a rule literally labelled "C6" in SKILL.md, references/contract-review.md or dispatch-gates.mjs at origin/main (grep C6 hits only ADR-0131/0133 and audit prose); the rule cited above is the one this review is rendered under. The declaration no holds: nothing moves an accept set, a published key, a type or a default; no refusal is deleted (the diff removes no throw/process.exit); no packages/** file is touched. Changeset: the PR adds and changes no .changeset row (git diff origin/main..head -- .changeset empty, 21:25:20Z), so route 0 does not match; files[] scan at head over packages/**/package.json: 70 manifests with files[], 217 entries, 0 reaching outside their package directory; each of the three changed paths is inside no package directory; firing control packages/spec/dist/index.js → shipped by packages/spec. Check Changeset on the opened run (job 106939918746, completed 21:13:48Z) is failure; its log prescribes, verbatim: "This PR adds no changeset. FIRST: if its only .changeset rows are CHANGED, not added, this PR corrects somebody else's pending release note -- do NOT apply 'skip-changeset' … Otherwise, if it releases nothing (including any 'skills/**' change -- see #5947), apply the 'skip-changeset' label; otherwise run 'pnpm changeset' and name the packages." with route 2 "It releases nothing (.github/, .claude/, skills/, docs/, content/, examples/, tests-only, and the like) -> apply the 'skip-changeset' label. <<< PREFERRED". Applying or withholding that label is the seat's act, not this record's.

Check-runs (newest run per check name, read from GitHub — never re-run locally)

Read at 2026-09-22T21:25:20Z (first read 21:14:12Z, when 9 were in_progress; final read 21:28:53Z, see the final-read line in ③). Newest per name: Check Changeset skipped (run 35785552466, started 21:15:56Z — the job's if: skips only when the payload carries skip-changeset or the release branch; the earlier opened run 35785112597 stays failure and, by the job's own comment, "the red left here does not clear itself"); Auto Label skipped (older run success); Check PR Size skipped (older success); TypeScript Type Check success; Type Check · source gates / debt ledger / consumer gates / workspace all success; Test Core success and shards 1–6/6 all success; Build Docs success; Dogfood Regression Gate success (matrix shards skipped); Governed Surface Queue Guard success; Spec property liveness success; Check Documentation Links success; Part-of PR must not also close its card success; The card this PR closes must claim this branch success; No other open PR may claim the same issue / …same single-writer path success; filter success; Build Core, Console Pin Gate, Temporal Conformance, Dogfood Verify CLI, Packed-tarball smoke skipped. Lint & Repo Gates (job 106939919933, the job that runs check-tenant-audit-census.mjs --self-test and the check) was still in_progress at 21:25Z — NOT MEASURED, not a pass. See the final-read line at the end of ③.

② Semver level

None. Nothing this PR changes is inside any published package's files[] (70 manifests / 217 entries / 0 outside their own directory; the three paths are scripts/**, content/docs/**, docs/audits/**), so no tarball's bytes move and no package needs a bump. The changeset gate's own prescription for that case is route 2, the skip-changeset label — not an empty changeset (route 3 is closed) and not route 0 (no .changeset row is added or changed). Whether the label is applied is the seat's decision.

③ Boundary flags

  • F1 (blocking, item 1): opensCallableScope is applied to every callee shape at head:854, so a BARE-IDENTIFIER call resolves through the enclosing class body; a top-level function getEngine(): IProbeEngine shadowed by a same-named METHOD of the enclosing class is silently subtracted (other/platform-type) in both declaration orders at head, where origin/main answered engine/IProbeEngine in one; this.x.getEngine() from inside a class with its own getEngine does the same (X4). Latent today (0 corpus receivers), unpinned, in the direction the census header says it cannot survive.
  • F2 (pre-existing, unchanged): this inside a function expression nested in a method is not the instance; the class chain, like the old bare map, still says the class.
  • F3 (pre-existing, acknowledged by the PR as a separate card): class member types are not indexed by shapes, so x.method() across two classes conflates. X4 sits at the junction of F1 and F3; the X1/X2 half is this card's predicate, not that card.
  • F4: the opened run's Check Changeset red does not clear itself; the live label list at 21:26Z reads documentation, size/m, skip-changeset (the label landed at ~21:15:48Z, not by this record — this record makes no GitHub writes), which is why the labeled run (35785552466) shows the job skipped. The route the gate log prescribes (route 2) and the label now present agree.
  • F5: Lint & Repo Gates — the gate for the census self-test and the artefact check — was NOT MEASURED at every read in this run (see the final-read line below); the local exit-0 in item 6 is a re-derivation, not the gate.
  • F6: the PR's 73 shadowed declarations vs this record's 42 shadowed names are different counting bases; the six conflicting pairs agree exactly.
  • Fallback/overload notices: none observed in this run.

Final check-runs read, 2026-09-22T21:28:53Z (newest run per check name): unchanged from the 21:25Z table above — every listed gate success or skipped as stated, Check Changeset newest skipped (older opened run failure), and Lint & Repo Gates (job 106939919933, started 21:11:46Z) still in_progress — NOT MEASURED at the last reading of this run; the seat must read its conclusion before treating the census self-test and the artefact check as passed in CI.

Implemented-by: claude/issue-19652-census-class-scope-keys
Reviewed-by: session_013RDBh5DqXd2xnLwvHLgLFr

VERDICT: FAIL — opensCallableScope walks a bare-identifier callee through the enclosing class body, so a top-level function shadowed by a same-named method is silently subtracted from the certified population at head (probe X1: origin/main → engine/IProbeEngine, head → other/platform-type), a new unpinned silent-subtraction shape in the instrument that publishes the page; the zero, the floor, the pins, the artefacts and the changeset reading are otherwise all verified as claimed.


Tier control — measured by the SEAT from the reviewer's transcript

  • 89 assistant requests, 89 stamped claude-fable-5-1 — every per-request "message":{"model":…}.
  • 193/193 on the superset: 104 "model" plus 89 "advisorModel", all the same value.
  • Dark control: empty.
  • Fallback / overload sweep: 25 keyword hits, ⛔ none a notice — tool-schema prose, the dispatch order's own sentence, and the reviewer's own "none observed".
  • CONTRACT_REVIEW_TIER re-read at source: origin/main:scripts/pm/dispatch-gates.mjs — export const CONTRACT_REVIEW_TIER = 'claude-fable-5-1';

⚠️ This record is adopted verbatim, FAIL and all. ⛔ A seat does not rewrite, soften or re-verdict a subagent's ruling.

Two corrections to the record's own framing, ⛔ neither touching the verdict

  1. ⭐ 「I found no rule literally labelled "C6"」 is a corpus miss, ⛔ not an absence. C6 is defined at origin/main:scripts/pm/check-clause2-carriers.mjs — 「## C6 — a record owed on this head, and none behind it (finding(pm-dispatch): after #17285, default-tier lanes have no named "review of record" — five Clause-②: yes merges in one window carry none, and clearing the carrier is indistinguishable from never reviewing #17302; lane-keyed by charter conflict: does a Clause-②: no PR that touches no contract surface still owe an in-seat review before it can land? #18536)」 — and the same file carries C1…C9 as a family. The reviewer's grep did not reach that file. The seat's citation stands.
  2. ⚠️ But the seat's dispatch order named the WRONG basis, and this is the seat's error. origin/main:.claude/skills/pm-dispatch/references/contract-review.md opens its eligibility section with 「欠不欠按面判 ⛔ 不按车道:diff 碰下列任一面即欠达档复核」, and its 复核面 list names content/docs/**. This PR modifies content/docs/permissions/tenant-audit-census.mdx ⇒ the review is owed by the SURFACE, directly. Saying it was owed 「by the lane rule」 was a weaker and less accurate basis than the one that actually fires.

Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Seat disposition of the FAIL — accepted, confirmed at source, and going back to a dev. ⛔ Not landing as it stands.

Written by the domain:spec execution seat 1 (session_013RDBh5DqXd2xnLwvHLgLFr) at 2026-09-22T21:32Z, on the at-tier record 5784504091 (89/89, dark control empty). That record is adopted whole; this is the seat's disposition of it.

⭐ Everything else in this round verified. The thisProps → class-scope half is correct, the bare-key spelling is genuinely gone (0 hits at head against a firing control of 3 on main), the regenerated artefacts move exactly two provenance stamps, the ablations red exactly the right cases, and — the part this seat asked for hardest — the zero is a reading: the reviewer re-derived it independently and then made it move by adding a probe file (229 vs 228 sites, differing placement) and made it stop moving by removing it. ⇒ ⛔ that zero is not an instrument that fires at nothing.

The blocking finding, re-derived by the seat at source

At head, scripts/tenant-audit-census.mjs resolves every callee shape through one call:

:654   const fnReturns = scopedNames(opensCallableScope);
:553   function opensCallableScope(node) { return opensScope(node) || opensClassScope(node); }
:854   const entry = fname ? decls.fnReturns.lookup(fname, callee) : null;

against origin/main:806, which is flat: decls.fnReturns.get(fname).

⇒ a bare-identifier call — getEngine() — is looked up through the enclosing class body. ⛔ The language never does that: a method lives on the prototype, ⛔ not in lexical scope. So a class C { getEngine(): Map<…> } shadows a file-level function getEngine(): IProbeEngine at a call site that never referred to the method.

⭐ The file's own docblock is what makes the defect legible, and it is worth quoting because it is right about the thing it is reasoning about:

A method's name belongs to its class body, a function declaration's and a const fn = () => …'s to the block or file that declares it — so this is opensScope with the class added … a method named getEngine is class-scoped, while a function getEngine() two lines above the class is not, and a chain that saw only classes would lose the second one.

That reasoning is about where a callable's NAME is declared — and it is correct for recording a declaration. :854 uses the same chain to resolve a call site, which is keyed on how the call is written. ⇒ one chain is serving two different questions, and it can only be right for one of them.

⚠️ Why this blocks rather than becomes a follow-up

The reviewer's probe X1 has origin/main answering engine/IProbeEngine and head answering other/platform-type — a real engine write silently subtracted under the defended arm, which prints nothing and is counted nowhere. X2 shows head wrong in both declaration orders where main was wrong in one.

⇒ this PR would add a new, deterministic, silent-subtraction shape to the instrument that certifies tenancy safety, in the exact direction the census header names as the one it cannot survive — and none of the 8 added pins covers it. ⛔ 「latent today」 is not a defence: the card being fixed was also latent today, and that is precisely the argument this round made for fixing it.

⇒ Back to a dev on the same branch

The repair shape is in the record and is small: choose the chain by callee shape at :854 — a bare identifier walks opensScope only; this.m() walks the class chain; x.m() takes the floor — and pin X1 in both orders plus X4. ⛔ The PM does not write it.

Two corrections that are the seat's own, ⛔ not the round's

  1. ⚠️ The dispatch order named the wrong basis for this review. It said the review is owed 「by the lane rule」. origin/main:.claude/skills/pm-dispatch/references/contract-review.md opens with 「欠不欠按面判 ⛔ 不按车道:diff 碰下列任一面即欠达档复核」 and lists content/docs/** among the 复核面. This PR modifies content/docs/permissions/tenant-audit-census.mdx ⇒ it is owed by the surface, directly. The lane framing was weaker and less accurate than the rule that actually fires.
  2. C6 does exist, contrary to the record's note — origin/main:scripts/pm/check-clause2-carriers.mjs defines it («a record owed on this head, and none behind it»), alongside C1…C9. That grep simply did not reach the checker. ⛔ Recorded so nobody removes a citation that is sound.

State on this head

skip-changeset was applied by this seat at 2026-09-22T21:15:48Z after reading the gate's own job log, which names route 2 as PREFERRED and says route 0 does ⛔ not apply here (this PR has no .changeset row at all). The labeled re-run came back skipped. ⚠️ Lint & Repo Gates was still in_progress at the record's final read and remains so at 2026-09-22T21:32Z — NOT MEASURED, ⛔ not a pass. The fix push re-runs it either way.


Generated by Claude Code

…here the name was declared

`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. The lookup site used that same chain for every callee shape, so a bare
`getEngine()` was resolved through the enclosing class body, which the
language never does: a method lives on the prototype, not in lexical scope.

A `class C { getEngine(): Map<…> }` therefore shadowed a file-level
`function getEngine(): IDataEngine` at a call written `getEngine()` inside
`C` -- a real engine write subtracted under the DEFENDED `platform-type`
arm, which prints nothing and is counted nowhere.

`calleeScopeChain` picks the chain by callee shape: a bare identifier walks
the lexical scopes only, `this.m()` walks the class chain, and `x.m()` walks
neither and reads the file-wide floor, which is exactly what it resolved
through before. Recording is unchanged.

Four pins cover the three shapes: a bare call in both declaration orders
(the floor disagrees between them), `this.m()`, and `this.x.m()` from inside
a class with its own same-named method.

Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr
Co-authored-by: Claude <noreply@anthropic.com>
`--write` moves exactly the two provenance lines and no other byte, which
is the artefact-level statement that the callee-shape repair changes no
published figure: the corpus has no call site whose classification it moves.
The stamp now names a commit that carries the shipped instrument rather than
one that carries the defective one.

Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr
Co-authored-by: Claude <noreply@anthropic.com>

Copy link
Copy Markdown
Collaborator Author

The Test Core red is a SUPERSEDED-RUN artefact, ⛔ not a test failure — measured, and standing down on it

Seat note, domain:spec execution seat 1 (session_013RDBh5DqXd2xnLwvHLgLFr), 2026-09-22T21:49Z. ⛔ Not a review, ⛔ not a request to the round in flight.

A check_run.completed event reported Test Core = failure on bfd92001b1. Read at source rather than acted on:

reading value
workflow run 35787953113 status: completed, conclusion: cancelled, head bfd92001b1, event pull_request
its 15 jobs 8 success · 1 cancelled · 5 skipped · 1 failure
the cancelled one Test Core (1/6) — a shard, cancelled mid-run
the failed one Test Core — the rollup, whose own log contains no error at all: it merged 4 shard timing captures and uploaded its artefact cleanly
the rollup's own timing table 「Merged from 4 shard capture(s) (1/6, 2/6, 3/6, 4/6)」 — ⇒ the run did not reach all six

⇒ the rollup reported failure because one of its needs: was cancelled, ⛔ not because a test failed. Zero test assertions failed in that run. And cancelled is NOT MEASURED — ⛔ neither green nor red.

Why it was cancelled is the ordinary reason: bfd92001b1 was superseded seconds later by a36551a559, and the concurrency group cancelled the older run. ⇒ this red belongs to a sha that is no longer this PR's head.

The reading that governs

The PR's current head is a36551a559. Read at 2026-09-22T21:49Z: 25 check names — 11 success, 7 skipped, 0 failure, 7 still running. ⚠️ Those 7 are NOT MEASURED, ⛔ not a pass; they are simply not finished.

⇒ ⛔ No fix is pushed and no re-run is spent, and neither is a punt: there is no failure to root-cause. ⛔ Re-running a cancelled, superseded run would measure nothing — the run that matters is already in flight on the current head. This seat keeps the PR watched and will read that head's conclusions when they land.

⚠️ For anyone reading the event feed later: ⛔ do not bind a check reading to a webhook's sha. This one named a head the PR had already left.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

⚠️ The head has MOVED past the contract-review record — that record ⛔ does not carry across

Seat note, domain:spec execution seat 1 (session_013RDBh5DqXd2xnLwvHLgLFr), 2026-09-22T22:06Z. ⛔ Not a review and ⛔ not a verdict.

The at-tier record 5784504091 is bound to bee92afe12 and says so on its own Head-sha: line. This PR's head is now a36551a559, two commits later:

bfd92001b1  fix(census): resolve a call site by how the call is written, not by where the name was declared
a36551a559  docs(census): re-stamp the census artefacts on the callee-shape fix

⇒ ⛔ Do not read the FAIL as still standing, and ⛔ do not read it as cleared. A record binds ONE head. The head it judged no longer exists as the tip, and the head that exists has no record.

What the seat can see on the new head — structure only, ⛔ not a verification

Read at 2026-09-22T22:06Z: scripts/tenant-audit-census.mjs gains a calleeScopeChain(callee) whose identifier arm returns the lexical predicate alone, the call site becomes decls.fnReturns.lookup(fname, callee, calleeScopeChain(callee)), and the self-test grows 75 → 79 cases. That is the shape the record's repair sketch described.

⛔ That is a reading of the diff, not of its behaviour. Nothing here confirms the probes move, that the new pins can fail, or that the published population still holds — and the round that pushed these commits died before reporting, so ⛔ none of it was ever verified or handed back.

What happens next

A verification round is dispatched on this same head: re-derive the probes in both declaration orders, ablate the new pins to prove they can go red, re-run the published-population comparison with controls, and update this PR body. A fresh at-tier review on the head that results follows it. ⛔ Until both land, this PR has no standing record.

⚠️ For anyone reading the thread from the top: the two Test Core / TypeScript Type Check reds in the event feed belong to the superseded sha bfd92001b1 and are cancellation artefacts, measured in 5784746647. ⛔ They are not this head's.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Served-tier: 86/86 CONTRACT_REVIEW_TIER (seat-measured, 2026-09-22T23:07Z)
Head-sha: a36551a

Contract review

Contract review of objectstack-ai/objectstack PR #19749 (Fixes #19652, branch claude/issue-19652-census-class-scope-keys) at head a36551a559f0a0f9d36bce8184dcb5dfe913461b, base main = c120dbdfb81743a172622b7aec6d81bb9f2ed9bf (merge-base re-derived equal to origin/main at 2026-09-22T22:43:47Z). Diff vs main: 3 files, +219/−29 — scripts/tenant-audit-census.mjs +217/−27, and one provenance-stamp line in each of content/docs/permissions/tenant-audit-census.mdx and docs/audits/2026-08-tenant-audit-write-call-sites.counts.md. Six commits on the branch; the two since the earlier record's head bee92afe12 are bfd92001b1 (script only, +94/−7) and a36551a559 (the two stamp lines only). Rendered by an isolated at-tier subagent for seat domain:spec#1; no GitHub writes, nothing pushed, the PR untouched.

A record binds one head. The earlier record (comment 5784504091, bound to bee92afe12, FAIL) is neither inherited nor cleared by reference: every number below is re-derived on this head, including the shapes that record blocked on.

Method. origin/main fetched fresh (22:43:31Z); the head fetched into an owned ref refs/review/pr-19749-a36551a559 and checked out detached into a scratch worktree, never over the shared tree; main read only via git show origin/main:PATH. typescript@6.0.3 (the lockfile version) was supplied from a copy owned by this run — a first attempt symlinked a sibling worktree's package, which vanished mid-run and voided one batch of readings (disclosed under ④ below); every reading cited here was taken after the owned copy was in place, and all self-test/ablation readings were taken through node scripts/check-tenant-audit-census.mjs --self-test, the command CI runs. Gate conclusions come only from this head's GitHub check-runs (table under ⑧); every local run of the census or the check is a re-derivation, never a gate reading. Worktree and ref removed at 23:02:38Z. Nothing in the PR body, the seat's comments or the earlier record is used as evidence.

Tier. origin/main:scripts/pm/dispatch-gates.mjs:12282 reads, verbatim (22:43Z): export const CONTRACT_REVIEW_TIER = 'claude-fable-5-1';. The served-tier control is the seat's, from the transcript's per-request harness model stamps; this record's self-report is not admissible and is not offered. No fallback or overload notice was observed at any point in this run.

Why this review is owed. By the surface, not the lane: origin/main:.claude/skills/pm-dispatch/references/contract-review.md 「欠不欠按面判 ⛔ 不按车道:diff 碰下列任一面即欠达档复核」 and its 复核面 list names content/docs/**; this PR modifies content/docs/permissions/tenant-audit-census.mdx. The path limb of clause ② (packages/spec/src/**) does not fire — no such path is touched. No governed surface (docs/adr/**, docs/NORTH-STAR.md, .claude/**, skills/**, AGENTS.md, CLAUDE.md) is in the file list.

① Derived judgments

1. The callee-shape repair — each arm is correct, and the compiler agrees on every shape probed

At head (scripts/tenant-audit-census.mjs, read from the owned ref at 22:44Z): calleeScopeChain(callee) (:592–598) returns opensScope for a bare identifier, opensClassScope for this.m, and null otherwise; scopedNames.lookup(name, node, chain = opens) (:664–673) walks scopeChainOf(node, chain) only when chain is non-null and then falls to the file-wide floor; the one call-site consumer is :905 decls.fnReturns.lookup(fname, callee, calleeScopeChain(callee)). Recording (note(fnReturns, n, …) at :764 and :818, under opensCallableScope = opensScope || opensClassScope) is unchanged from bee92afe12.

Probed by running all three module versions — origin/main, bee92afe12, head — on the same synthetic sources through the self-test's own path (declaredTypesIn → resolveReceiver → nonEngineReason, IProbeEngine in the index; 22:50:42Z), and asking tsc --noEmit --strict (typescript 6.0.3) on the same sources, where the write verb insert exists only on the engine type, so exit 0 means the compiler resolved the call to the engine and TS2339 means it resolved to the Map:

probe shape origin/main bee92afe12 head tsc
X1 bare getEngine() inside a class, file-level function first engine platform-type engine exit 0 (engine)
X2 same pair, class first platform-type platform-type engine exit 0 (engine)
X3 this.getEngine() beside a same-named file-level function engine platform-type platform-type exit 2 TS2339 (Map)
X4 this.x.getEngine(), x typed A (engine), inside B with its own getEngine (Map), A first engine platform-type engine exit 0 (engine)
E3 bare call shadowed by a block-local const getEngine = (): Map… engine platform-type platform-type exit 2 (Map)
E5 this.getEngine() inside a nested class EXPRESSION whose own method returns a Map engine platform-type platform-type exit 2 (Map)
E6 this.getEngine() inside an arrow inside a method engine platform-type platform-type exit 2 (Map)
D1–D3 dark controls: single function; single method via this.; two different names engine / platform-type / engine, platform-type same same 0 / 2 / 0

Bare identifier → lexical only: X1, X2, E3 are the compiler's answers. this.m() → class chain: X3, E5 (innermost class expression wins), E6 (arrow transparent) are the compiler's answers. receiver.m() → floor: X4 restores the origin/main answer the pre-fix head had subtracted. The three shapes the earlier record blocked on (X1, X2, X4) all read engine at this head; that finding is closed at this head by measurement. The body's two tsc claims (X1 exit 0; X3 exit 2 with TS2339: Property 'insert' does not exist on type …) reproduce exactly.

2. Does the repair introduce anything new? — ONE shape, in the silent direction, where origin/main was right (finding F1, blocking)

The lookup side is now right per callee shape. The recording side is where the remaining gap is. ts.isMethodDeclaration(n) at :818 is true for an object-literal method (const o = { getEngine() {…} }) as well as for a class method, and scopeChainOf(n, opensCallableScope)[0] for an object-literal method is the enclosing block (an ObjectLiteralExpression opens no scope under any predicate). So an object-literal method is recorded as if it were a lexical callable of the block it sits in, and a bare f() in that block is resolved to the object's method — which the language never does.

probe shape origin/main bee92afe12 head tsc
E7 file-level function getEngine(): IProbeEngine; inside w() a const o = { getEngine(): Map… } and a bare getEngine().insert(…) engine platform-type platform-type exit 0 (engine)
E7r same, file-level function declared after w() platform-type platform-type platform-type exit 0 (engine)

At head a real engine write that origin/main PLACED is subtracted under the DEFENDED platform-type arm — the direction the census header names as the one it cannot survive — and where main was wrong in one declaration order the head is wrong in both for every bare call inside that block. It is not among the 79 pins, and the self-test's success line (:2621ff, "a bare f() resolves lexically and is never shadowed by a same-named method") and the calleeScopeChain docblock ("a BARE IDENTIFIER is a lexical name and nothing else") both claim the property this shape falsifies. The re-stamp commit's message ("the callee-shape repair changes no published figure") is true; the docblock's universal claim is not.

  • Corpus exposure today: zero, with controls (head module, 576 sources, 22:56Z): 98 object-literal methods; 0 files in which an object-literal method shares a name with a same-file function declaration or arrow-const; 12 bare-identifier-callee write receivers, all createHash() / createHmac() (node builtins, the node-import arm); 0 exposed. Scanner firing control: a one-file synthetic tree with the E7 shape reports 1 pair and 1 exposed receiver. For calibration, the shape the earlier record blocked on has the same exposure: 1325 class methods, 0 files where a class method shares a name with a same-file function/arrow-const. Latent is the same defence it was last round, and it was not accepted then.
  • Repair, verified here on a variant of the head module (23:01:22Z): opensCallableScope also opening ts.isObjectLiteralExpression(node) records an object-literal method under its literal, which no lookup chain walks, leaving it reachable only through the floor exactly as before. With that one line: E7 and E7r answer engine; X1–X4, E3, E5, E6 and the dark controls are unchanged; selfTest() still passes 79/79; runCensus({ root }) over the head tree is byte-identical to the head's (sha256 1e7fed94…). The finding wants that line plus a pin in both declaration orders.

Pre-existing shapes, unchanged by this PR and not findings against it (all three module versions agree): E8, a class property holding an arrow (getEngine = (): Map… ⇒ …) called as this.getEngine() is read from the floor (a same-named file-level function → engine, a Map admitted; tsc exit 2); E11, a bare call to a name declared only as a method resolves through the floor where the compiler reports TS2663; super.m() takes the floor (main's answer restored from the pre-fix head's).

3. The ZERO — a reading, with the right comparison and working controls

  • Is one tree the right comparison? Yes, and for this PR it is also the only comparison there is: the census walks packages/services and packages/plugins and indexes packages + examples, and git diff --name-only main..head -- packages examples is empty (22:56Z). The corpus is the same object under every instrument; only the instrument varies. Two checkouts would differ solely in measuredAt.ref, which runCensus({ root }) reads from the root's HEAD.
  • Three instruments over the one head tree (22:56:17Z): origin/main, bee92afe12 and head modules each produce 112739 bytes, sha256 1e7fed942bc2320893a926ed6299905547fcaadc7a97b13ab0accbe1d797d21f, byte-identical to each other and to the head CLI's own --json (22:53:11Z). Totals: 227 write call sites, 153 decidable, 74 undecidable, 576 sources, 63 engine types, 144 non-engine subtractions, platform-type 3, 0 unresolved, 0 unledgered.
  • Firing control (one tracked file packages/services/zz-review-x2-probe/src/probe.ts, X2 shape with IDataEngine): origin/main and bee92afe12 read 227 sites / 145 subtractions / platform-type 4; head reads 228 sites / 154 decidable / 144 / platform-type 3, and the only-in-head site is the probe's getEngine() receiver placed IDataEngine by declared type. The comparison moves for both main-vs-head and pre-fix-vs-head, for the reason claimed: the subtraction the repair removes.
  • Dark controls: instrument-vs-itself on the augmented tree is byte-identical (head/head, pre-fix/pre-fix); a single-declaration probe (no shadowing) reads 228 sites on all three instruments, byte-identical; the probe removed, head output returns byte-identical to the 22:56 reading; worktree git status --porcelain empty afterwards.

4. The four ablations — the pins can fail, and the widened reach is composition, not a second job

Through scripts/ablation-replace.mjs (anchor count and blob hash prove the mutation, blob == HEAD 1298ffcb7085 and empty git diff HEAD prove each restore), wrapping node scripts/check-tenant-audit-census.mjs --self-test, 23:00:42–23:01:12Z; baseline 79 + 27 cases pass:

ablation mutation reds
A0 lookup(fname, callee, calleeScopeChain(callee)) → lookup(fname, callee) 3 of 79: the two bare-call pins and the x.m() FLOOR pin, each got other/platform-type
A1 the this. arm return opensClassScope → return null 1 of 79: the this.m() pin, got engine/IProbeEngine
A2 opensClassScope → return false 6 of 79: the four class-scope pins, plus X2 and X3
A3 opensCallableScope → return false 4 of 79: the two callable-scope pins, plus X2 and X3

No ⛔ FLOOR-labelled pin is among the reds under A2 or A3. A0 and A1 together red all four new pins, so none is vacuous. The widening is explained at source: under A2 opensCallableScope degenerates to opensScope, so a class method is RECORDED at the SourceFile scope where in X2 it is the first typed getEngine and wins over the later function (a recording-side red), while the this. chain is empty and X3 falls to the floor (a lookup-side red); under A3 every callable is recorded under the null scope, no chain reaches any entry, and both X2 and X3 are answered by the floor. That is one predicate composed into two places, each doing the job its name says — not the earlier defect, where a recording union was applied as the lookup chain for every callee shape. The gap that remains (item 2) is in what the recording predicate recognises, not in the two-jobs sense.

Self-test counts, from selfTest() called directly on each module: 67 at origin/main, 75 at bee92afe12, 79 at head (23:01Z).

5. The regenerated artefacts — generator output, stamp moved and nothing else, not on the os-regen roster

  • node scripts/tenant-audit-census.mjs --write in the head worktree (22:50:51Z): git diff --stat 2 files / 2 insertions / 2 deletions; the only changed lines are Measured on 2026-09-22 at \bfd9200`.→`a36551a55`.in each artefact. Restored withgit checkout HEAD -- THE_TWO_ARTEFACT_PATHS`; porcelain empty.
  • node scripts/check-tenant-audit-census.mjs (local re-derivation, not the gate reading) 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, every write call site placed and every UNTYPED_RECEIVERS row matched."
  • The re-stamp commit a36551a559 diffs exactly the two stamp lines (90379e052 → bfd92001b); bfd92001b1 touches only the script. measuredAt() (:349–361) is git rev-parse --short=9 HEAD at generation time, so a commit that regenerates can only ever name its parent: the committed stamp naming bfd92001b (the last commit that changes the instrument) is the fixed point the body describes, and check-tenant-audit-census.mjs masks that line whole (UNENFORCED_SCALE_ROWS / maskUnenforced, :488–554 at origin/main) so its value is required to be present and dated, never equal. The argument is sound.
  • git check-attr merge at head (22:44:35Z): unspecified for the script, both artefacts and the check script; firing control packages/spec/spec-changes.json, content/docs/permissions/system-context.mdx, content/docs/references/x.mdx → os-regen. .gitattributes byte-identical between main and head. The Lint & Repo Gates job (origin/main:.github/workflows/lint.yml, job lint at :164, named at :179) runs check-tenant-audit-census.mjs --self-test and the check at :2245–2246.

6. The clause-② declaration and the changeset

The declaration Clause-②: no on the PR body and on the card's Claim: comment (5783415521, line-leading there) holds against the diff: no packages/** path, no published schema, key, type, default or export moves, no refusal is removed, and the diff removes no throw or process.exit. The changeset reading: 70 manifests under packages/ declare files[], 217 entries, 0 climb out of their package directory (23:02:37Z); the three changed paths are scripts/, content/docs/, docs/audits/ — under packages/: 0. No .changeset row is added or changed, so route 0 (only .changeset rows are CHANGED) does not apply; the gate's own text at origin/main:.github/workflows/pr-automation.yml:794–796 reads「2. It releases nothing (.github/, .claude/, skills/, docs/, content/, … -> apply the 'skip-changeset' label」, marked PREFERRED in that text. The label is present at both PR reads (≈22:51Z, 23:02Z), and the newest Check Changeset run on this head is skipped by the job's own if:. Who applied it and when is the seat's statement; this record has no timeline read and makes no GitHub write. The route is right.

7. The four acceptance notes — each correctly out of scope; note 1's new paragraph is right

  1. Class members not indexed — verified: N1 (two classes with getEngine, called through parameters a: A, b: B) answers engine | engine on all three module versions with A first and platform-type | platform-type with B first, while the same members declared as interfaces are told apart (engine | platform-type). The new order-dependence paragraph is correct and reproduces: X4 answers engine with A first and platform-type with B first at head, tsc exit 0 in both orders, and the pre-fix head answers platform-type in both — the pinned X4 case agrees with the compiler by the floor's first-typed-wins rule, not by reading A's members. Different mechanism (shapes indexes interfaces and type-literal aliases only, :740–751); a separate card is right.
  2. classifyHttp reads an absent header as zero — confirmed at source by reading, not by reproducing the live 403: origin/main:scripts/pm/label-write.mjs:344 if ((status === 403 || status === 429) && Number(rateRemaining) === 0) return 'ratelimit';, with rateRemaining: null produced at :382 and :402/:407 when the header is absent; Number(null) === 0. A defect in scripts/pm/, not this file — out of scope, correctly noted.
  3. The FLOOR is kept — a boundary, pinned in the FLOOR cases, which stay green under every ablation; correct.
  4. Note 2 not re-measured this round — a candid statement, consistent with item 2 above.

8. Check-runs on this head — read from GitHub, newest run per check name

First read ≈2026-09-22T22:51Z, final read 2026-09-22T23:02Z, identical: 39 check runs, all completed; none in_progress, none failure, none cancelled. The seat's body PATCH (PR updated_at 22:42:07Z) re-fired only the pr-automation family at 22:42:11Z — Auto Label, Check PR Size, Check Changeset newest skipped (older runs success / success / skipped), the four claim/part-of/single-writer checks success — and nothing heavier. Required contexts: Lint & Repo Gates success (run 35788706216, job 106952133704, 21:48:44Z–22:19:04Z), TypeScript Type Check success, Test Core success (shards 1–6 success), Dogfood Regression Gate success, Governed Surface Queue Guard success, Build Core skipped, Temporal Conformance (live PG + MySQL) skipped. Others: Type Check · workspace / source gates / debt ledger / consumer gates success, Build Docs success, Spec property liveness success, Check Documentation Links success, filter success; Console Pin Gate, Dogfood Verify CLI, Packed-tarball smoke, the Dogfood matrix shards skipped. The reds the thread mentions belong to the superseded sha bfd92001b1 and do not appear on this head.

② Semver level

None. Nothing this PR changes is inside any published package's files[] (70 / 217 / 0 climbing out; the three paths are outside packages/), so no tarball's bytes move and no package needs a bump; the gate's own prescription for that case is route 2, the skip-changeset label, which is present. The declaration no is consistent with that.

③ Boundary flags

  • F1 (blocking, item 2): an object-literal method is recorded as a lexical callable of its enclosing block, so a bare f() in that block resolves to the object's method; a real engine write origin/main placed is silently subtracted under the DEFENDED platform-type arm at head (E7), deterministically inside the block, in both declaration orders. Unpinned. Zero corpus exposure today (0 pairs, 0 exposed receivers, with a firing control) — the same exposure the earlier record's blocking shape had. Verified one-line repair: opensCallableScope additionally opening ObjectLiteralExpression, plus a pin in both orders; the variant keeps 79/79 green and the population byte-identical.
  • F2 (pre-existing, not this PR's): a class property holding an arrow function, called as this.m(), is read from the floor (E8) — the property map and the callable map do not meet; main, pre-fix and head agree.
  • F3 (pre-existing): this inside a function expression or an object-literal method nested in a class method is not the instance, and the class chain — like the old flat map — still answers the class.
  • F4 (pre-existing, tooling): node scripts/tenant-audit-census.mjs --self-test is not a mode the generator's main() handles at head or at origin/main: it prints the ordinary census report and exits 0, a false green for anyone wrapping the wrong command (this run did, and caught it by the absence of a verdict line). The self-test's only driver is check-tenant-audit-census.mjs --self-test. A follow-up card, not a rider.
  • F5 (disclosure): one ablation batch in this run was voided when a sibling worktree's typescript vanished (ts-parse: PREREQUISITE NOT MET, exit 3) and a second batch wrapped the generator instead of the check (F4); both are recorded in the scratch directory and neither is cited above. All cited ablation readings are the 23:00:42–23:01:12Z batch on the owned copy through the check.
  • F6: the earlier record's blocking finding (bare-call and this.x.m() shapes) is cleared at this head by measurement (X1, X2, X4), not by inheritance.
  • Fallback / overload notices: none observed.

Implemented-by: claude/issue-19652-census-class-scope-keys
Reviewed-by: session_013RDBh5DqXd2xnLwvHLgLFr

VERDICT: FAIL — the head records an object-literal method as a lexical callable of its enclosing block, so a bare f() in that block resolves to the object's method and a real engine write that origin/main placed is silently subtracted under the defended platform-type arm (probe E7: main → engine/IProbeEngine, head → other/platform-type, tsc → engine), a second deterministic silent-subtraction shape in the certifying instrument, unpinned; the callee-shape repair itself, the zero and its controls, the four ablations, the artefacts and the changeset reading are otherwise verified as claimed, and the one-line repair is validated above.


Tier control — measured by the SEAT from the reviewer's transcript

  • 86 assistant requests, 86 stamped claude-fable-5-1 — every per-request "message":{"model":…}.
  • 190/190 on the superset: 104 "model" plus 86 "advisorModel", all the same value.
  • Dark control: empty.
  • Fallback / overload sweep: 36 keyword hits, ⛔ none a notice — tool-schema prose, the dispatch order's own sentence, the reviewer's own "none observed", repo text about a 403 fallback arm and a queue-unavailable fallback, and the reviewer's own shell echo FALLBACK to global typescript.
  • CONTRACT_REVIEW_TIER re-read at source: origin/main:scripts/pm/dispatch-gates.mjs — export const CONTRACT_REVIEW_TIER = 'claude-fable-5-1';

⚠️ This record is adopted verbatim, FAIL and all. ⛔ A seat does not rewrite, soften or re-verdict a subagent's ruling. It supersedes the earlier record 5784504091, which binds head bee92afe12 and whose three blocking shapes (X1, X2, X4) this record measures as cleared at this head.


Generated by Claude Code

…ts block

`ts.isMethodDeclaration` is true of `const o = { getEngine() {…} }` as well as
of a class method, and an object literal opens 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: a name declared only that
way is `TS2304: Cannot find name`.

Measured at the previous head: a file-level `function getEngine(): IProbeEngine`
beside a block-local object literal declaring `getEngine(): Map<string, number>`
answered `other/platform-type` for a bare `getEngine().insert(…)` in that block,
in BOTH declaration orders, while `origin/main` answered the engine in one of
them and `tsc --noEmit --strict` answers the engine in both. That is a real
engine write subtracted 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.

`opensCallableScope` now also opens an object literal, 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 the method stays reachable through the file-wide
FLOOR exactly as before and no receiver that resolved stops resolving.

Pinned in both declaration orders; the docblocks and the self-test success line
no longer claim a property the shape falsified.

Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr
Co-authored-by: Claude <noreply@anthropic.com>
The second new pin was worded exactly like the class-scope one two cases
above it, so an ablation that reds both prints the same line twice and the
reader cannot tell which pin failed -- which is the whole value of a named
case. Ablation A3 reds both, so this is not hypothetical.

Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr
Co-authored-by: Claude <noreply@anthropic.com>
`node scripts/tenant-audit-census.mjs --write` at the instrument commit. The
whole diff is the provenance stamp line in each artefact; every enforced
figure is byte-identical, because the published population did not move --
227 write call sites, 153 decidable, 74 undecidable over 576 sources, the
same bytes the `origin/main` and pre-fix instruments produce over this tree.

Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr
Co-authored-by: Claude <noreply@anthropic.com>

os-support-ai commented Sep 23, 2026 •

Copy link
Copy Markdown
Collaborator Author

Contract review

VERDICT: FAIL — one blocking finding, adopted by the seat verbatim and dispatched back for a third round. ⛔ Not softened, ⛔ not deferred to an acceptance note.

Head reviewed: b73fd40aa058e1c8d32e77aec0e7bf08d58097c8. Base main, merge-base c120dbdfb8.

Reading moment: the reviewer's run closed and this record was written at 2026-09-23T00:09Z — a contract-review record is a snapshot bound to one head and one reading moment, ⛔ not a standing statement about CI. The authoritative reading for any landing is the seat's own, taken at the landing moment. Isolated at-tier subagent, worktree of its own, shared checkout never touched.

Reviewed-by: isolated at-tier subagent dispatched by domain:spec execution seat 1 (session_013RDBh5DqXd2xnLwvHLgLFr), seat post #6017
Implemented-by: claude/issue-19652-census-class-scope-keys

Tier — measured by the SEAT from the reviewer's own transcript, ⛔ not from its self-report

CONTRACT_REVIEW_TIER on origin/main (scripts/pm/dispatch-gates.mjs) is claude-fable-5-1. ⚠️ The shared checkout carries the older claude-opus-5 and is ⛔ not the authority.

reading value
assistant request stamps in the transcript 99 / 99 claude-fable-5-1
superset (every "model" + "advisorModel" key) 223 / 223 the same value
dark control — any claude-opus-5" stamp 0
fallback / overload / unavailable keyword sweep 29 hits, ⛔ none a notice — every one read in context is tool-schema text, repo prose about a ?? fallback arm, or the reviewer's own 「none observed」 sentence

⇒ AT TIER. This seat is off-tier and ⛔ may not 自审; the record above is the isolated subagent's.

F1 (BLOCKING) — this.m() inside an object-literal method nested in a class member is read from the enclosing CLASS

The this. arm of calleeScopeChain returns opensClassScope, so this.getEngine() written inside a method of an object literal that sits inside a class member resolves to the class's getEngine. The language never does that: this inside an object-literal method is the literal.

probe origin/main c120dbdfb8 pre-fix a36551a559 head b73fd40aa0 tsc --noEmit --strict
R1 — literal-first engine/IProbeEngine other/platform-type other/platform-type exit 0 (engine)
R1r — class method first other/platform-type other/platform-type other/platform-type exit 0 (engine)
R2 — the literal's method is the only one (floor) engine engine engine exit 0

Through the real census pipeline, ⛔ not only a probe harness: one tracked file carrying R1 with IDataEngine, three instruments over one tree via runCensus({ root }) — origin/main reads 228 sites / 154 decidable / platform-type 3 and places the write (placedBy: declared-type); pre-fix and head read 227 / 153 / 145 subtractions / platform-type 4 and the site is gone. Probe removed ⇒ git status --porcelain empty and the head reading returns byte-identical.

Corpus exposure today is 0 (186 this-rooted write receivers over 576 sources, none inside a this-rebinding function that is not a class member), with a scanner firing control: the same scan with the probe tracked reads 187 / 1 and names the file and binder.

⭐ Why blocking and not a boundary note. This PR introduced it at bfd92001b1 (round 2). The round-2 record filed it as F3 「pre-existing … all three module versions agree」 — true only in the class-first order. In the literal-first order origin/main places the site and this head subtracts it, which is the same shape the round-2 review blocked on for E7 (head wrong in both orders where main was right in one; deterministic; the silent direction; unpinned; zero exposure). It also contradicts the PR's own contract at head — declaredTypesIn's docblock reads 「⛔ The repair may not cost a single site that resolved before」 — so the body sentence 「nothing that resolved before stops resolving」 is false at head on this shape.

The reviewer carries a validated scratch repair (thisContainerOf(site), ~21 lines, selfTest() 81/81, R1 and R1r → engine, one movement at R7 it names). It is handed to the round as a proposal to judge, ⛔ not as a patch to paste, and the round owes pins in both declaration orders plus check-tenant-audit-census.mjs --self-test from the repo root.

Non-blocking — three body sentences the seat owes, since os-dev.md:56 makes the body the seat's hand

  1. The e0f2e86741e4… hash binds to the 9a24005710 tree, ⛔ not b73fd40aa0; at this head the same JSON hashes 075734a25f19…. The reviewer reproduced the round-2 record's 1e7fed94… exactly at a36551a55, so its method is byte-exact. The population claim itself holds — three instruments over one tree, 112739 bytes, 227 / 153 / 74, platform-type 3, 0 unresolved, 0 unledgered. Prose precision only.
  2. 「Four things were found and deliberately NOT fixed here」 heads a list of five.
  3. A Lint & Repo Gates run id sits stale inside the a36551a559 section; at this head it is run 35797018139, job 106978668423, success.

Verified as claimed — no finding

Round-2's F1 is closed, at no cost: object-literal shapes in both orders, as a call argument, nested, against a same-block arrow, in a class property initializer, and returned from an arrow all read engine at head with tsc agreeing; the floor still answers for a method called from outside its container. The union is closed by construction (the TS AST parents a MethodDeclaration only to ClassDeclaration / ClassExpression / ObjectLiteralExpression), with the corpus histogram matching. Ablations A0–A4 reproduce exactly (3 / 1 / 6 / 5 / 2 of 81), each with anchor-count and blob proofs on mutation and restore, and A3's fifth red is composition rather than breakage. Artefacts move only their two stamp lines. Scope is the card.

CI at this head, read by the reviewer at the end of its run

39 runs, all completed; 0 failure, 0 in_progress, 0 cancelled. Lint & Repo Gates — in progress at the seat's earlier read — completed success. The skipped rows are designed: Auto Label and Check PR Size are excluded by their own if: on the edited event (both were success on the 23:21 push), Check Changeset by the skip-changeset label, and six more by the path filter.

⇒ the head is green. It is the diff that fails review, which is the distinction this record exists to make.


Generated by Claude Code

`calleeScopeChain`'s `this.` arm returned `opensClassScope`, so `this.m()`
written inside an OBJECT LITERAL's method resolved to the method of whatever
class enclosed the literal. The language never does that: `this` inside an
object-literal method is the literal. Measured, that SUBTRACTED a real engine
write `origin/main` placed, under the defended `platform-type` arm -- the one
direction this census must never fail in.

`thisContainerOf` walks to the member that owns the site's `this` and hands
back ITS container: an arrow is transparent, a `function` expression or
declaration names no container at all (the floor), and a method, accessor,
constructor, property declaration or static block stops the walk at its own
parent -- a class for a class member, the object literal for a literal's
method. A plain `key: value` is not a stop, so `{ k: this.engine }` in a class
method still reads the class. A miss inside the named container falls to the
file-wide FLOOR and never outwards to a class that merely encloses the site.

Twelve pins, each shape in both declaration orders where the floor decides.

Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr
Co-authored-by: Claude <noreply@anthropic.com>
`node scripts/tenant-audit-census.mjs --write` at the instrument commit. Two
lines in total -- the unenforced corpus-scale block's provenance stamp in each
artefact. Every ENFORCED figure is byte-identical: the population did not move.

Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr
Co-authored-by: Claude <noreply@anthropic.com>

Copy link
Copy Markdown
Collaborator Author

Contract review

VERDICT: PASS — nothing blocking. Head reviewed: 93555d0fcddcc0e385290f136f37d12e52dcbf2c. Base main, merge-base c120dbdfb8. This supersedes the b73fd40aa0 FAIL record only for this head; that record still binds its own.

Reading moment: this record was written at 2026-09-23T01:30Z. A contract-review record binds ONE head and one reading moment.

Reviewed-by: isolated at-tier subagent dispatched by domain:spec execution seat 1 (session_013RDBh5DqXd2xnLwvHLgLFr), seat post #6017
Implemented-by: claude/issue-19652-census-class-scope-keys

Tier — measured by the SEAT from the reviewer's transcript

CONTRACT_REVIEW_TIER on origin/main is claude-fable-5-1. Governing assistant-request stamps 84 / 84 claude-fable-5-1; dark control (any non-claude-fable-5-1 stamp) empty; the fallback/overload sweep read in context is tool-schema text and repo prose — ⛔ none a notice. ⇒ AT TIER.

What was proven

  • F1 is closed in both declaration orders, through the real pipeline. Three instruments over one tree with the probe tracked: R1 — origin/main 228 sites and PLACES the write, the pre-fix head 227 with the site gone, this head 228 byte-identical to origin/main; R1r — main and pre-fix 227, this head 228, naming the site. Dark control byte-identical on all three; probe removed ⇒ back to 227, porcelain empty.
  • The repair costs nothing origin/main placed. 36 probes × three instruments, each against tsc 6.0.3 --noEmit --strict. No probe moves from engine at origin/main to non-engine at this head except the class-scope repairs rounds 1 and 2 already landed, each agreeing with the compiler. All twelve rows of the body's probe table reproduce exactly.
  • The pins can fail. A5 — the direct ablation — reds exactly 6 of 93, each carrying the pre-fix answer; A0–A7 match the body's table; each mutation and restore proven on disk. Three ablations of the reviewer's own design red the pins they should.
  • The population is still zero, compared raw and with measuredAt.date masked, all three instruments within one UTC date: 112739 bytes, 227 / 153 / 74, 576 sources, platform-type 3, 0 unresolved, 0 unledgered, 0 of 227 change classification.
  • Scope: the census script and its two artefacts, nothing else.

Four non-blocking findings — and what the seat did with each

finding disposition
F1 the round-3 population hash 4c35ebc1b26c… binds to the 8f8828bf66 tree (the JSON's measuredAt.ref), ⛔ not 93555d0fcd fixed in the body by this seat — a PR-body sentence, so the head did not move
F4a the scanner's 184 does not reproduce (the review reads 186, or 182 by raw text); the load-bearing 0 and +1 do fixed in the body: the total is stated as definition-dependent, the 0 and the +1 carry the claim
F4b 「no run had been scheduled on 93555d0fcd」 was stale when written — Lint & Repo Gates ran and succeeded there, 00:27:18Z → 00:57:46Z fixed in the body
F2 thisContainerOf treats every function expression as naming no container; a function expression that is an object literal's key: value is typed by TypeScript as that literal (probe X1 compiles, this head subtracts) acceptance note 7 — ⛔ not a cost of this PR (origin/main answers identically), exposure 0, carrier the next PR to this file
F3 the R7b ruling's arguments ① and ② over-generalise: an inner class expression that extends the outer class compiles (probe R7x), and the census models no inheritance at all acceptance note 8 — ③ carries the ruling and stands; ⛔ not a cost against origin/main; exposure 0

⇒ The PR's own contract — 「the repair may not cost a single site that resolved before」 — holds on every probe the reviewer built, including the two that expose gaps in the new predicate's model.

CI at this head

39 check runs, all completed; 0 failure, 0 in_progress, 0 cancelled. Designed skips: Check PR Size and Auto Label on the edited event (their own if:; both succeeded on the push), Check Changeset on the skip-changeset label, the path-filtered build/dogfood/console jobs (this PR's three paths hit only the docs and crosspkg filters), and the opt-in tarball smoke.

⛔ This record is the review; the landing reading is the seat's own, taken separately at the landing moment.


Generated by Claude Code

@os-support-ai
os-support-ai marked this pull request as ready for review September 23, 2026 01:32
@os-support-ai
os-support-ai added this pull request to the merge queue Sep 23, 2026
Merged via the queue into main with commit e99a14c Sep 23, 2026
48 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-19652-census-class-scope-keys branch September 23, 2026 02:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants