Skip to content

fix(scripts): tenant-audit census stores declared type text as written, and resolves names by scope - #19650

Merged
os-support-ai merged 6 commits into
mainfrom
claude/issue-19300-census-type-text-roundtrip
Sep 22, 2026
Merged

os-support-ai merged 6 commits into
mainfrom
claude/issue-19300-census-type-text-roundtrip

Conversation

@os-support-ai

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

Copy link
Copy Markdown
Collaborator

Fixes #19300

Two defects in one file's TypeScript reading, with two independent fixes. Route ① for
the first, as triage on the predecessor card named it; scope-keyed name resolution for
the second.

Defect 1 — the census collapsed declared type text before re-parsing it

declaredTypesIn stored a declared type through getText(sf).replace(/\s+/g, ' '). The
door rule is later read off that text by re-parsing it as a synthetic alias
(type CensusReceiver = THE_STORED_TEXT;). A type literal may separate its members by
a NEWLINE alone — legal TypeScript — and the collapse turns that separator into nothing,
so the synthesis does not parse and the site is refused as
type-text-not-round-trippable. The author was told to restyle legal TypeScript to work
around the tool's own re-serialisation.

The text is now stored exactly as the source spells it. The collapse was never needed at
the storage boundary: every reader of that text either scans it for identifiers or
re-parses it, and the one place a single line is genuinely required — the artefacts —
already collapses at the point of render (runCensus, cell()).

The real site set is TWO, not the three the card names. Measured on this branch:

site in the set? why
note()'s type field yes feeds readTypeTextDoor through fromEntry
the shapes member type yes feeds it through memberTypeOfShapes
receiverKey no a receiver's own spelling, for ledger keys and diagnostics; never re-parsed
note()'s init field no a diagnostic, truncated to 120 chars; scanned by regex, never re-parsed
the as path's r.type.getText(sf) no never collapsed in the first place — it is the lit control that already proved raw text round-trips
the spread / opaque / runtime-name sites no diagnostics and object names, never type texts
:355 in memberIsEngineDoor no replace(/\s+/g, '') — an empty replacement, and its only consumer is t !== 'string'. It normalises for an equality test and is never handed to a parser.

Defect 2 — locals was keyed by bare identifier, with no scope

A file may contain several engines. Keyed on the identifier alone they were one entry,
and the first TYPED one decided for all of them. That is three failures, not one, and
which one you get depends on declaration order:

shape before after
engine-typed engine declared first, Map second Map.delete scored an engine write and admitted to the certified population the Map is subtracted
Map first, engine-typed second the real engine write subtracted as platform-type — a DEFENDED arm, so it prints nothing and is counted nowhere the engine write is placed
both engine-typed, one unreadable both refused (the card's measured instance) both placed

The middle row is the expensive one and the card does not name it: the census's own header
calls a silent subtraction the direction it cannot survive being wrong in, and the
bare-name map was producing them.

The conflation also decided two other verdicts the artefacts carry — which object a
site writes (resolveObjectNameArg) and whether it is elevated (elevationOf). Both
are pinned now.

declaredTypesIn resolves a name through its innermost enclosing scope, with the old
file-wide map kept as an explicit floor tier: consulted only when no enclosing scope
declares the name, so no receiver that resolved before stops resolving.

What this moves on the real corpus

The population is unchanged — 227 sites before, 227 after, none added, none removed.
Fourteen sites change attribution, and one receiver becomes honestly unreadable:

  • 6 sites stop needing an UNTYPED_RECEIVERS row (the census can now read their declared
    type), so three ledger rows are deleted as stale.
  • 2 sites in bootstrap-declared-webhooks.ts resolve their object name to sys_webhook
    for the first time — undecidable becomes enabled.
  • 6 sites in metadata-store.ts move from declared-type to the object-name rescue.
    Their receiver is a contextually typed arrow parameter ((engine) => engine.insert(…)
    against a declared run: (engine: IDataEngine) => …), which needs a type checker this
    module does not have. The bare-name map had been answering it from an unrelated
    declaration and happened to be right; the verdict is unchanged either way.
  • 1 receiver — const db = context.database in auth-manager.ts — was being answered by a
    a Record keyed by string declared 2000 lines away and silently subtracted as
    platform-type. It is the better-auth adapter bound to the SCIM transaction, and it
    arrives in UNTYPED_RECEIVERS saying so.

✅ Closed: the artefacts are regenerated in this PR

This PR's originally declared file surface was scripts/tenant-audit-census.mjs plus its
self-test and fixtures, and the defect-2 repair moves the census totals, which stales the
committed artefacts. The dev stopped at that fence and declared the red rather than
widening silently. The seat then widened the fence — the artefacts ride the source PR
rather than a follow-up card, per the lane rule 「其重生成的 references 产物归源 PR」.

Both steps are done:

  1. node scripts/tenant-audit-census.mjs --write regenerated
    content/docs/permissions/tenant-audit-census.mdx and
    docs/audits/2026-08-tenant-audit-write-call-sites.counts.md.
  2. The hand-written prose numbers --write does not touch were taken from the gate's own
    output
    , not retyped from any summary: const 41→43, unreadable-and-tenancy-enabled
    32→34 (twice), statically decidable 151→153, undecidable 76→74.

check-tenant-audit-census.mjs and its --self-test are now green (27/27, up from 6
failing)
, which also confirms the diagnosis: all six failures were the drift.

Defect 1 is artefact-neutral and was measured as such: with only the two storage-site
lines changed, the census --json is byte-identical to the base tree (diff exit 0 over
the full document). Every count that moves comes from defect 2.

Verification

All 66 gates scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands
derives for the widened diff were run and reconciled with --ran on the final head:
66 derived / 66 run / 0 NOT MEASURED / 0 UNRUN, every family carrying a recorded exit
code, all zero.
The set grew from 29 because the diff now reaches content/docs/**,
docs/audits/** and ts-parse.mjs.

⚠️ Five families first returned PREREQUISITE NOT MET (three needing @objectstack/lint
built, one packages/spec/json-schema, one @objectstack/client-react declarations).
They were recorded as NOT MEASURED, never as clean, and re-measured to exit 0 after
two builds through scripts/pm/os-verify-lock.sh.

Reverse verification — three ablations through scripts/ablation-replace.mjs, each run
from the committed state with the on-disk mutation proven and the restore verified
blob == HEAD with git diff HEAD empty:

ablation red
re-collapse note()'s type 3 of 67 — the inline, parameter-annotation and non-door newline cases
re-collapse the shapes member type 1 of 67 — the interface-member case only, proving the two storage sites are independently reached
drop the lexical tier, leave only the floor 4 of 67 — both declaration orders, the object name and the elevation

The self-test grows 57 to 67 cases. The type-text-not-round-trippable arm is kept and
still exercised
: a genuinely unparseable text is still refused and still places nothing.
What changed is what it means — it is now the alarm for a defect in this module's own
re-serialisation, never an instruction to restyle the corpus, and the refusal text says so.

Acceptance notes

  • ✅ scripts/check-tenant-audit-census.mjs's refusal prescription ("spell the
    literal's members with ; separators so the stored text round-trips") was made false by
    this change and is the copy CI prints. Fixed in this PR — text only. It no longer
    asks the author to restyle anything; it says the fault is the census's own and to file it
    there.
  • ✅ scripts/ts-parse.mjs's header documented the collapse as a live defect. Fixed
    in this PR
    — text only, now historical. ⛔ It deliberately does NOT retire the door it
    documents: the measurement is a fact about the PARSER, which has not changed, and any
    caller re-parsing text it synthesised itself can fail the same way.
  • The same conflation survives one map over, and it is reproducible today. Two classes
    in one file sharing a property name: class B's this.engine.delete('k') on a
    a Map of string to number scores engine/IProbeEngine — a Map admitted to the certified
    tenancy population. fnReturns behaves the same way for two same-named local functions.
    thisProps needs CLASS scope rather than lexical scope, which is a different notion from
    the one this PR adds, so it is reported rather than folded in. Filed by the seat as
    [finding] tenant-audit-census keys thisProps and fnReturns by bare identifier too — two classes sharing a property name conflate, and a Map is ADMITTED to the certified tenancy population (the direction the census header says it cannot survive being wrong in) #19652.
  • Contextually typed parameters are unreadable to this census, before and after — see
    the metadata-store.ts note above. A capability gap, not a regression; recorded so the
    6 sites' move to the object-name rescue is not read as one.
  • This change makes tenant-audit census gate self-test: the clean-census control is coupled to the live tree via refuse #19299 acutely live. That card is the census self-test's
    clean-census control being coupled to the live tree through refuse. The coupling was
    latent while the tree was clean; with the artefacts drifted, 6 of the gate's 27
    self-test cases now fail, and 5 of them are controls
    asserting that a stale number is
    NOT a finding. They fail for the reason they exist to deny. ⛔ Not folded in — it is a
    different defect and deliberately serialised behind this card.

Generated by Claude Code

os-support-ai and others added 3 commits September 22, 2026 03:39
… by scope

Route ① for the census's unreadable-type-text class: the declared type
text is stored exactly as the source spells it, so a type literal that
separates its members by a newline alone -- legal TypeScript -- still
re-parses as the synthetic alias the door rule is read off. The collapse
stays at the presentation boundary, where it already lived.

`declaredTypesIn` resolves a name by its innermost enclosing scope, with
the old file-wide map kept as a floor so no receiver that resolved before
stops resolving.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr
… in all three directions

The NEWLINE pin flips from a per-site refusal to a placement -- same rule,
same text, the separator now survives storage -- and two further storage
sites (a parameter annotation, an interface member) are pinned beside it.

The scope battery pins what the bare-name map actually cost: a `Map.delete`
scored an engine write, a real engine write subtracted as a language
global, and the object name and elevation read from the wrong scope. The
file-wide floor is pinned too, so nothing that resolved before stops.

Three UNTYPED_RECEIVERS rows go: the census can now read those receivers'
declared types, so the rows had outlived the code they described. One
arrives: the better-auth adapter the SCIM transaction binds, which the
bare-name map had been answering with an unrelated `Record` 2000 lines away.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr
…he corpus

The arm survives -- a genuinely unparseable derived text is still refused --
but "stored whitespace-collapsed" is no longer true of it, in the header,
in the reason string, in the refusal and in the block both artefacts carry.
What it means now is that this module could not read back a text it derived,
which is a defect to file against the census rather than a restyle to ask
of legal TypeScript.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr
os-support-ai and others added 3 commits September 22, 2026 04:12
…he repaired reading

`node scripts/tenant-audit-census.mjs --write`, plus the four hand-written
prose numbers the gate names and `--write` does not touch.

The population is unchanged at 227 sites. What moves is attribution: six
sites stop needing an `UNTYPED_RECEIVERS` row and are read by their declared
type instead, six move the other way onto the object-name rescue, and
`bootstrap-declared-webhooks.ts` resolves its object name to `sys_webhook`
for the first time -- undecidable becomes enabled, which is where the two
extra tenancy-enabled sites come from. `migrate-webhook-secrets.ts` stays
undecidable in the same table, because there the name really is a bare
typed parameter.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr
Text only, no behaviour: the gate's refusal no longer tells an author to
spell the literal's members with `;` separators, because the stored text is
no longer collapsed and nothing at the site is wrong. It now says what the
generator's copy says -- this tool could not read back a text it derived,
which is a report to file against the census.

`ts-parse.mjs`'s fourth-door section described that collapse in the present
tense. Past-tensed, with the reason the door is NOT retired alongside it:
the door is about text a process synthesised, never about one cause of it.
The dated parser measurement stays -- it is a fact about TypeScript.

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

Copy link
Copy Markdown
Collaborator Author

CI red is THIS PR's, declared in advance, and the repair is in flight — 2026-09-22T04:28Z

Seat note from domain:spec seat 1 (session_013RDBh5DqXd2xnLwvHLgLFr, seat post #6017). ⛔ Not a review verdict; the round is still open.

What is failing. Lint & Repo Gates, read from the job log first-hand (⛔ not from the dev's report):

failed at step #71: Tenant-audit census matches the tree

Error classes, all of one family: 2 × [census-drift] (content/docs/permissions/tenant-audit-census.mdx and docs/audits/2026-08-tenant-audit-write-call-sites.counts.md, both 151 committed vs 153 produced) and 5 × [prose-count] (41→43 · 32→34 · 151→153 · 76→74 · 32→34), plus check-tenant-audit-census self-test: 6 of 27 case(s) failed. Zero other error classes.

Why it is not a surprise. This PR's defect-2 repair moves the census totals, which stales the two committed artefacts. Both sit outside the file surface this round was dispatched with, so the dev stopped at the fence and declared the red rather than widening silently. That was the instructed behaviour and it is graded as correct. The seat has since widened the fence — the artefacts are regenerated on this branch, not in a follow-up card.

⚠️ Do NOT read 「1 failed」 as 「everything else is green」. The job's own tail reporter says it plainly:

This job stops at the first non-zero exit, deliberately … So the red above is a LOWER BOUND on the number of problems in this tree, not a count.

NEVER RAN (the unmeasured tail): 114

⇒ 114 of 185 declared gates are NOT MEASURED on this head, ⛔ not passed. The seat will not treat this PR as green on anything but a head where that tail actually ran.

⭐ One reading worth recording, because it is evidence for another card

Two of the six failing self-test cases are, verbatim:

✗ ⭐ a measurement date from months ago is NOT a finding
✗ ⭐ a stale unenforced prose number is NOT a finding

Those are controls — cases that exist to assert a stale number is not a finding. They fail for exactly the reason they were written to deny, because the gate's self-test reads the live tree through refuse. That is #19299's defect, and it was latent only while the tree was clean.

⇒ this is CI independently confirming #19299 rather than the dev's report asserting it, and it is why that card has been re-priced UP (5771... on #19299). ⛔ #19299 stays serialised and out of this PR — different defect, different fix.

⛔ No re-run requested: 「flake」 is not in question here, the cause is established, and the fix is a push away.


Generated by Claude Code

@github-actions github-actions Bot added the documentation Improvements or additions to documentation label Sep 22, 2026
@os-support-ai
os-support-ai marked this pull request as ready for review September 22, 2026 05:17
@os-support-ai
os-support-ai added this pull request to the merge queue Sep 22, 2026
Merged via the queue into main with commit f2101d0 Sep 22, 2026
48 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-19300-census-type-text-roundtrip branch September 22, 2026 05:49
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…able names (objectstack-ai#19749)

Fixes objectstack-ai#19652

Clause-②: no

`scripts/tenant-audit-census.mjs` keyed two of its three name maps on
the **bare identifier**. `thisProps` holds class property types;
`fnReturns` holds what a callable returns. Two classes in one file
sharing a property name were ONE entry and the first TYPED one decided
for both, and every consumer looked the name up by bare name too.

The census builds `content/docs/permissions/tenant-audit-census.mdx`,
which ships under `content/docs/**`, so a wrong verdict publishes a call
site to customers as tenancy-safe when it is not.

## The probe, re-taken on today's `origin/main` — not inherited from the
card

Taken at `origin/main` @ `16d090ede0`, 2026-09-22T20:17:28Z, before a
line was changed. The card's probe is the objectstack-ai#19300 dev's and the card says
whoever takes this card re-takes it. Receiver verdicts in source order;
`IProbeEngine` is in the engine type index, the other type is a Map
keyed by string (`MAP_OF_STRING_TO_NUMBER`, spelled this way to keep the
generic's angle brackets out of a GitHub body).

| probe | shape | before | after |
|:--|:--|:--|:--|
| P1 | thisProps: two classes, constructor-injected `engine`, engine
declared first | `engine/IProbeEngine \| engine/IProbeEngine` |
`engine/IProbeEngine \| other/platform-type` |
| P2 | thisProps: same pair, other declaration order |
`other/platform-type \| other/platform-type` | `other/platform-type \|
engine/IProbeEngine` |
| P3 | thisProps: property DECLARATIONS rather than constructor
parameters | `engine/IProbeEngine \| engine/IProbeEngine` |
`engine/IProbeEngine \| other/platform-type` |
| P4 | fnReturns: two same-named local functions, engine first |
`engine/IProbeEngine \| engine/IProbeEngine` | `engine/IProbeEngine \|
other/platform-type` |
| P5 | fnReturns: same pair, other declaration order |
`other/platform-type \| other/platform-type` | `other/platform-type \|
engine/IProbeEngine` |

P1 reproduces the card's prediction exactly: a `Map.delete` ADMITTED to
the certified tenancy population. P2 and P5 are the direction the card
predicted only in prose and the census header names as the one it cannot
survive — a real engine write SUBTRACTED as `platform-type`, a DEFENDED
arm that prints nothing and is counted nowhere. P3 is a storage site the
card's probe does not reach.

**Controls, all taken on the same instrument in the same run, all
unchanged before and after.** FIRING: a lone Map-typed `this.engine` is
refused (`other/platform-type`) and a lone engine-typed one is accepted
(`engine/IProbeEngine`) — so the door is not inert in either direction.
DISCRIMINATION: the same two types under DIFFERENT property names, and
under different function names, are told apart. DARK: the identical
shape on PARAMETERS is already correct, because that is the lexical tier
objectstack-ai#19300 landed (PR objectstack-ai#19650) — so the failure is specific to the two maps
this card names. DARK: with `IProbeEngine` removed from the index the
same source answers `other/type-not-in-corpus`, so a verdict is not
constant.

## The fix — the landed tier extended, not a second mechanism beside it

`scopeChainOf` and `scopedNames` now take the scope predicate. All three
name maps are `scopedNames` instances under the predicate that matches
how the language scopes that kind of name:

- `thisProps` — `opensClassScope` (class declarations and class
expressions). `this.engine` in class `B` is `B`'s property and can be
nothing else.
- `fnReturns` — `opensCallableScope`, the lexical scopes PLUS the class.
A method's name belongs to its class body; a `function` declaration's or
a `const fn = …`'s to the block or file that declares it. Not the
class-only predicate: a `function getEngine()` two lines above a class
is not class-scoped, and a chain that saw only classes would lose it.
- `locals` — unchanged.

Each keeps that structure's file-wide FLOOR, so a name no enclosing
scope declares resolves exactly where it used to and nothing that
resolved before stops resolving.

The bare-key spelling is REMOVED rather than guarded: `note()` now
requires the declaring node, and no `.get(name)` lookup survives on
either map. There is no spelling left to reach for by accident.

## Before/after on the published population — it did not move, and that
is a measurement

Full `--json` census, before and after, on the same tree: **227 write
call sites, 153 decidable, 9 tenancy-enabled sites provably carrying no
tenant context, 34 more unreadable — byte-identical.** Every array
(`sites`, `nonEngineCalls`, `undefendedSubtractions`, `engineTypes`,
`declaredObjects`) and `nonEngineReasons` are identical too. **Zero call
sites change classification, in either direction.**

That zero is measured rather than assumed. `scopedNames().lookup(name,
null)` skips the scope chain and returns the file-wide tier, whose
insertion rule is byte-for-byte the old bare-name `note()` — so
comparing it against `lookup(name, node)` at every write-call receiver
IS the before/after diff, on one code path:

- **Corpus scan, 576 sources: 98 divergent receivers across 44 files —
every one of them a `local`**, i.e. the tier objectstack-ai#19300 already landed. The
kinds this card changes (`this.PROP`, `this.BASE.member`, `fname()`)
diverge at **0** receivers. The 98 is the firing control: the same
instrument, the same corpus, the same run, answering non-zero.
- Synthetic firing controls on that identical scan function return 1
each for the P1 and P4 shapes; dark controls (different names; a single
declaration) return 0.

**The conflating shape is nevertheless already in the corpus — the
defect was latent, not absent.** The same instrument, applied at
DECLARATION nodes, finds 73 shadowed declarations, 6 of them with
genuinely conflicting declared types:

| file:line | map | name | scoped reads | bare read |
|:--|:--|:--|:--|:--|
| `packages/services/service-messaging/src/http-outbox.ts:462` |
thisProps | `code` | a three-arm literal union |
`'DELIVERY_NOT_ELIGIBLE'` |
| `packages/plugins/plugin-email/src/email-service.ts:644` | fnReturns |
`send` | `SendEmailResult` | `TransportSendResult` |
| `packages/services/service-sms/src/sms-service.ts:113` | fnReturns |
`send` | `SendSmsResult` | `SmsTransportSendResult` |
| `packages/services/service-settings/src/settings-service.ts:1509` |
fnReturns | `get` | `T[K]` | a resolved-setting value |
| `packages/services/service-automation/src/engine.ts:3239` | fnReturns
| `execute` | no annotation | an automation result |
| `packages/plugins/plugin-security/src/security-plugin.ts:5790` |
fnReturns | `isScalarId` | a three-arm predicate | a two-arm predicate |

None of those six names is the receiver of a write call today, which is
exactly why the population does not move. One write call through any of
them and it would have.

## Reverse verification — the pins can fail

Eight self-test cases were added (67 to 75, all pass). Both ablations
ran through `scripts/ablation-replace.mjs`, which proves the mutation
landed on disk by anchor count and blob hash and proves the restore by
blob-equals-HEAD plus an empty `git diff HEAD`:

- `scopedNames(opensClassScope)` replaced by a predicate that opens
nothing: **exactly the 4 class-scope cases go red**, each with the `got`
string this PR measured at `origin/main` (`engine/IProbeEngine |
engine/IProbeEngine`). The two FLOOR cases stay green.
- Same for `opensCallableScope`: **exactly the 2 callable-scope cases go
red.** The FLOOR cases stay green.

## The regenerated page

`node scripts/tenant-audit-census.mjs --write` on the merged tree, the
repo's own tooling, never a hand edit. It rewrites two lines in total —
the unenforced corpus-scale block's provenance stamp in each artefact.
Every ENFORCED figure is byte-identical. `node
scripts/check-tenant-audit-census.mjs` exits 0: "227 write call sites
certified (153 decidable; 9 tenancy-enabled sites PROVABLY carry no
tenant context, 34 more unreadable), 23 prose figures held to the
census".

Neither `content/docs/permissions/tenant-audit-census.mdx` nor
`docs/audits/2026-08-tenant-audit-write-call-sites.counts.md` is routed
`merge=os-regen` (`git check-attr merge` answers `unspecified` for both,
and for the script). `origin/main` was merged in as an ordinary merge
with zero path overlap, so `scripts/pm/os-regen-merge.sh` has nothing to
do here.

## Verification

`node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands` derived **63 families** from this worktree on the final
tree; **63 run, 0 NOT-MEASURED, 0 UNRUN, and none exited 3**, reconciled
by `--ran` with every exit code recorded (captured before any pipe).
Four of them first answered PREREQUISITE NOT MET on an unbuilt worktree
and named what they needed; after the builds they named
(`@objectstack/spec`, `@objectstack/formula`, `@objectstack/lint`, then
`@objectstack/client`, `@objectstack/client-react`) all four are green.

`pnpm lint` — the repo-wide `eslint . --no-inline-config`, CI's own
scope, not a narrowing — exits 0 at `bee92afe12`.

Two artifact-roster families whose roster sits under `scripts/` were run
beyond the derived set: `pnpm check:dts-closure` exits 0 after the
builds; `pnpm check:published-readme-exports` remains **exit 3 / NOT
MEASURED**, needing every package's `dist` declarations, which a diff
touching no package source cannot supply. It is not a pass and not a
finding.

## Changeset

**This PR needs the `skip-changeset` label, and this branch cannot apply
it** — the dispatch order withholds label writes from this seat, so the
seat that dispatched it owns that write. `Check Changeset` will sit red
until then: the job counts changesets added against the merge base and
exempts only the label or the changesets release branch, and this PR
adds none.

The reading behind that route, rather than a guess: 70 package manifests
declare a `files[]`, 217 entries in all, and **0 of them reach outside
their own package directory**. All three changed paths live at the
repository root — `scripts/`, `content/docs/`, `docs/audits/` — outside
every package directory, so no published tarball's bytes move. Positive
control on the same matcher: `packages/spec/dist/index.js` resolves as
SHIPPED by `packages/spec`. Dark control: the near-miss
`scripts/tenant-audit-censuss.mjs` resolves as shipped by nothing.

## The callee-shape repair — a call site is resolved by how the CALL is
written

Commits `bfd92001b1` and `a36551a559`, verified independently on head
`a36551a559` by a later round that re-derived every number below rather
than inheriting it.

`opensCallableScope` is the predicate a callable's name is RECORDED
under: a method's name in its class body, a function declaration's in
the block or file that declares it. The lookup site read that same union
for **every** callee shape, so a bare `getEngine()` written inside
`class C` resolved through `C`'s body — which the language never does,
because a method lives on the prototype and not in lexical scope.
`calleeScopeChain(callee)` now picks the chain by callee shape: a bare
identifier walks the lexical scopes only, `this.m()` walks the class
chain, and a `receiver.m()` call walks neither and reads the file-wide
floor, which is exactly what it resolved through before. Recording is
unchanged.

### The movement, measured on three trees

Receiver verdicts for the same synthetic sources, taken through the
census's own exported `declaredTypesIn` / `resolveReceiver` /
`nonEngineReason` on three checkouts of this repository. `IProbeEngine`
is in the engine type index; the other type is a Map keyed by string
(`MAP_OF_STRING_TO_NUMBER`, spelled this way to keep the generic's angle
brackets out of a GitHub body).

| probe | shape | `origin/main` `c120dbdfb8` | pre-fix head `bee92afe12`
| this head `a36551a559` |
|:--|:--|:--|:--|:--|
| X1 | bare `getEngine()` inside a class, function declared first |
`engine/IProbeEngine` | `other/platform-type` | `engine/IProbeEngine` |
| X2 | the same pair, class declared first | `other/platform-type` |
`other/platform-type` | `engine/IProbeEngine` |
| X3 | `this.getEngine()` beside a same-named file-level function |
`engine/IProbeEngine` | `other/platform-type` | `other/platform-type` |
| X4 | `this.x.getEngine()`, `x` an `A` returning an engine, inside a
`B` with its own same-named method | `engine/IProbeEngine` |
`other/platform-type` | `engine/IProbeEngine` |

X1 and X4 are the regression the at-tier review found: a real engine
write that `origin/main` PLACED was SUBTRACTED at the pre-fix head under
the DEFENDED `platform-type` arm, which prints nothing and is counted
nowhere — the direction this census's header names as the one it cannot
survive. X2 is an improvement over both heads. X3 is the class-scope
half this PR already landed, and the repair leaves it standing.

**The language is the authority here, and it was asked.** `tsc --noEmit
--strict` on the X1 source exits **0** — the bare call resolves to the
file-level function, so `insert` exists on it — and on the X3 source
exits **2** with `TS2339: Property 'insert' does not exist on type
'MAP_OF_STRING_TO_NUMBER'`, so a `this.` call does read the class's own
method. The two verdicts this head gives are the two the compiler gives.

### The new pins can fail — four ablations, each proven on disk

All four ran through `scripts/ablation-replace.mjs`: anchor count plus
blob hash prove the mutation landed, blob-equals-HEAD plus an empty `git
diff HEAD` prove the restore. There is no build leg to reach —
`scripts/tenant-audit-census.mjs` is executed from source, so
`ablation-dist-preflight` has no `dist` to preflight for it.

| ablation | mutation | self-test result |
|:--|:--|:--|
| A0 | the lookup reverted to the pre-fix spelling, `lookup(fname,
callee)` | **3 of 79 red** — X1, X2, X4, each `got other/platform-type`,
exactly the pre-fix answer |
| A1 | the `this.` arm returns the floor instead of the class chain |
**1 of 79 red** — X3, `got engine/IProbeEngine` |
| A2 | `opensClassScope` opens nothing | **6 of 79 red** — the 4
class-scope cases, plus X2 and X3 |
| A3 | `opensCallableScope` opens nothing | **4 of 79 red** — the 2
callable-scope cases, plus X2 and X3 |

A0 and A1 together red all four new cases, so not one of them is
vacuously true. The four FLOOR cases stay green under A2 and A3, as they
did before. **A2 and A3 now red more cases than they did before
`bfd92001b1`, and that is the repair being load-bearing rather than a
regression**: `calleeScopeChain` hands back those two predicates, so
neutralising either one now also breaks the lookup that reads it. The
earlier "exactly the 4" and "exactly the 2" readings in this body
describe the tree at `bee92afe12` and no longer describe this head.

Self-test case count: **67** on `origin/main`, **75** at `bee92afe12`,
**79** here. `node scripts/check-tenant-audit-census.mjs --self-test`
exits 0.

### The published population, re-measured — still zero, and still a
measurement

`runCensus({ root })` threads its root through every enumeration the
module makes, so both instruments were run against **one tree** rather
than two trees believed equal: the pre-fix module from `bee92afe12` and
this head's module, both over this head's checkout. The `--json` bytes
are **identical** — 112739 bytes, `sha256
1e7fed942bc2320893a926ed6299905547fcaadc7a97b13ab0accbe1d797d21f` — at
**227 write call sites, 153 decidable, 74 undecidable, 576 sources, 144
non-engine subtractions, 0 unresolved receivers**. No call site changes
classification, in either direction.

The zero has controls, on the same comparison:

- **FIRING** — add one tracked corpus file carrying the X1 shape and the
comparison MOVES: the pre-fix instrument reports 227 sites with
`platform-type 4`; this head reports 228 sites, 154 decidable,
`platform-type 3`; and the diff names the added site. The subtraction
the repair removes is exactly the one the review predicted.
- **DARK** — the same augmented tree compared instrument-against-itself
is byte-identical in both directions, so the movement is attributable to
the instrument version and to nothing else. Remove the added file and
the comparison returns to byte-identical.

Across the two checkouts the only differing byte in the whole census
object is the provenance `ref` it records (`a36551a55` against
`bee92afe1`), which is what the re-stamp commit exists to move.

### The two re-stamped artefacts

`node scripts/tenant-audit-census.mjs --write` at this head rewrites
**the provenance stamp line and no other byte** in either artefact:
re-run on a clean tree, the entire `git diff` is two lines, each a
`Measured on 2026-09-22 at ...`. Every enforced figure is what the
generator produces at this head, and the tree was restored to its HEAD
blobs afterwards. `node scripts/check-tenant-audit-census.mjs` exits
**0**: "227 write call sites certified (153 decidable; 9 tenancy-enabled
sites PROVABLY carry no tenant context, 34 more unreadable), 23 prose
figures held to the census". The committed stamp names `bfd92001b`, the
last commit that changes the instrument — stamping the re-stamp commit
itself would need a further commit that would in turn be unstamped, so
this is the fixed point, and the gate deliberately does not compare that
value.

### Verification, re-derived from this head

`node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands` from a worktree at `a36551a559` derives **63 families** from
3 changed paths (248 changed lines, under the 5000 human-merge
threshold). All 63 ran with each exit code captured before any pipe and
reconciled by `--ran`: **63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN,
none exited 3.** One family first answered exit 3 / PREREQUISITE NOT MET
— `check:skill-examples`, which needs built `@objectstack/client` and
`@objectstack/client-react` declarations; after building those packages'
dependency closure it exits 0.

`Lint & Repo Gates` reported **success** on `a36551a559` (run
35788706216) and again on `b73fd40aa0` (run 35797018139, job
106978668423, `success`). ⛔ Each reading names the commit it was taken
on and is about no other: on `93555d0fcd` it reported **success** too
(run 35802103477, 00:27:18Z → 00:57:46Z). ⚠️ An earlier revision of this
body said no run had been scheduled on that head; that was stale when
written. Its two census steps were also run directly at each head —
`node scripts/check-tenant-audit-census.mjs` and the same script's
`--self-test` — exit 0 each.

### The object-literal repair — a member name is not a lexical one on
the RECORDING side either

Commits `9a24005710`, `496d65dc79` and `b73fd40aa0`, answering the
at-tier contract review's blocking finding F1.

The callee-shape repair kept the class body off a bare call's lookup
chain. That is only half of "a bare `f()` is never shadowed by a
method": a name is reachable through whatever scope it was RECORDED at.
`ts.isMethodDeclaration` is true of an OBJECT LITERAL's method as well
as a class one, and an object literal opened no scope under
`opensCallableScope` — so such a method was recorded as a lexical
callable of the enclosing BLOCK, and a bare `getEngine()` written in
that block resolved to the object's method. The language never does
that.

`opensCallableScope` now also opens `ts.isObjectLiteralExpression`, so a
member name is recorded under ITS CONTAINER exactly as a class method's
is. No lookup chain walks a container — `calleeScopeChain` reaches the
class for `this.m()` and nothing for the rest — so an object-literal
method stays reachable through the file-wide FLOOR exactly as before,
and no receiver that resolved stops resolving. The `thisProps` /
`opensClassScope` half, the `this.` arm and the floor are untouched.

| probe | shape | `origin/main` `c120dbdfb8` | pre-fix head `a36551a559`
| this head `b73fd40aa0` | `tsc` |
|:--|:--|:--|:--|:--|:--|
| E7 | file-level `function getEngine(): IProbeEngine`; inside `w()` a
`const o = { getEngine(): MAP_OF_STRING_TO_NUMBER … }` and a bare
`getEngine().insert(…)` in that block | `engine/IProbeEngine` |
`other/platform-type` | **`engine/IProbeEngine`** | exit 0 (engine) |
| E7r | the same, file-level function declared after `w()` |
`other/platform-type` | `other/platform-type` |
**`engine/IProbeEngine`** | exit 0 (engine) |

Re-taken here rather than inherited (2026-09-22T23:11:48Z at
`a36551a559`, 23:13:37Z after the fix), through the census's own
`declaredTypesIn` / `resolveReceiver` / `nonEngineReason`. Controls on
the same instrument and run: FIRING — the same block with no object
literal reads `engine/IProbeEngine`, so the door is not inert;
DISCRIMINATION — the same literal under a DIFFERENT member name reads
`engine/IProbeEngine`; DARK — with `IProbeEngine` out of the index the
fixed instrument reads `other/type-not-in-corpus`, so the verdict is not
constant. `tsc --noEmit --strict` (6.0.3, `--ignoreConfig`) exits 0 on
both E7 sources; on a third source where the object-literal method is
the ONLY declaration it exits 2 with `TS2304: Cannot find name
'getEngine'` — the compiler does not put an object-literal method in
lexical scope at all.

**The union is now closed for this corpus, measured rather than
argued.** Over all 576 sources at this head, every identifier-named
`MethodDeclaration` is parented by `ClassDeclaration` (1325) or
`ObjectLiteralExpression` (98), and every `FunctionDeclaration` by
`SourceFile` (1493), `Block` (9) or `ModuleBlock` (1). Both method
containers are now on the predicate and all three function containers
already were, so no recorded callable in this tree has an innermost
recording scope the predicate does not open.

### The new pins can fail — a fifth ablation, and the earlier four
re-read

All through `scripts/ablation-replace.mjs`: anchor count plus blob hash
prove each mutation landed, blob-equals-HEAD plus an empty `git diff
HEAD` prove each restore. Baseline `node
scripts/check-tenant-audit-census.mjs --self-test` exits 0 at **81 of
81** cases (79 + the two new ones) plus the check's own 27.

| ablation | mutation | self-test result |
|:--|:--|:--|
| A4 (new) | ` \|\| ts.isObjectLiteralExpression(node)` deleted |
**exactly 2 of 81 red** — both new pins, each `got other/platform-type`,
the pre-fix answer; nothing else moves |
| A0 | the lookup reverted to `lookup(fname, callee)` | 3 of 81 —
unchanged reach |
| A1 | the `this.` arm returns the floor | 1 of 81 — unchanged reach |
| A2 | `opensClassScope` opens nothing | 6 of 81 — unchanged reach |
| A3 | `opensCallableScope` opens nothing | **5 of 81 — moved from 4** |

A3's extra red is the new reversed-order pin, and it is composition
rather than breakage: with `opensCallableScope` returning false nothing
is recorded under any scope, every name is answered by the FLOOR, and in
that declaration order the floor holds the `Map`. No ⛔ FLOOR-labelled
pin reds under A2 or A3. **This supersedes the `3 / 1 / 6 / 4 of 79`
reading above, which describes the tree at `a36551a559`.**

### The published population, re-measured — still zero, with both
controls

Three instruments over **one tree** via `runCensus({ root })`
(2026-09-22T23:15:58Z at the `9a24005710` tree): the `origin/main`
`c120dbdfb8` module, the pre-fix `a36551a559` module and this head's
module each produce **112739 bytes, byte-identical** — at **227 write
call sites, 153 decidable, 74 undecidable, 576 sources**, 63 engine
types, 144 non-engine subtractions, `platform-type` 3, 0 unresolved, 0
unledgered. ⚠️ **A hash of that JSON identifies neither a tree nor an
instrument on its own**, so the reading above is stated with the tree
AND the day it was taken: `measuredAt()` returns `{ date: new
Date().toISOString().slice(0,10), ref }` and `runCensus` carries BOTH,
so the same tree and the same three instruments hash differently on two
different days. Measured: the `9a24005710` tree hashes `e0f2e86741e4…`
dated 2026-09-22; the `b73fd40aa0` tree hashes `075734a25f19…` dated
2026-09-22 and `c18ad4dc836a…` dated 2026-09-23, and masking
`measuredAt.date` alone makes those two byte-identical. The DRIFT gate
is unaffected — it masks the unenforced corpus-scale block out of its
comparison on purpose.

- **FIRING** — add one tracked corpus file carrying the E7 shape with
`IDataEngine` and the comparison MOVES in the predicted direction:
`origin/main` reads 228 sites / 154 decidable / `platform-type` 3 and
PLACES the write; the pre-fix head reads 227 / 153 / 145 subtractions /
`platform-type` 4 and SUBTRACTS it under the defended arm; this head
reads 228 / 154 / `platform-type` 3, byte-identical to `origin/main`.
The diff names the site.
- **DARK** — the same added file WITHOUT the object literal is
byte-identical on all three instruments at 228 sites, so the movement is
attributable to the shape and not to the added file. The probe removed,
`git status --porcelain` is empty and the comparison returns to the
byte-identical 227.

**No real call site moves**: 0 of 227 change classification in either
direction.

### The prose the shape falsified

Both docblocks and the self-test success line claimed a property E7
disproved. `opensCallableScope`'s now names the object literal as a
member-name container beside the class and says why omitting it cost the
same site; `calleeScopeChain`'s `f()` bullet records that excluding a
container from the lookup chain is only half of the property, the other
half being that the recording predicate put every member name under its
container; and the success line states the pinned property in both
orders for both containers.

### The two re-stamped artefacts

`node scripts/tenant-audit-census.mjs --write` at the instrument commit
rewrites **two lines in total** — the provenance stamp in each artefact,
`bfd92001b` → `496d65dc7`. Every ENFORCED figure is byte-identical,
because the population did not move. `node
scripts/check-tenant-audit-census.mjs` exits **0**: "227 write call
sites certified (153 decidable; 9 tenancy-enabled sites PROVABLY carry
no tenant context, 34 more unreadable), 23 prose figures held to the
census". Neither artefact was hand-edited.

### Verification, re-derived at `b73fd40aa0`

`node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands` from a worktree at this head derives **63 families** from
the same 3 changed paths (294 changed lines, under the 5000 human-merge
threshold). All 63 ran with each exit code captured BEFORE any pipe and
reconciled by `--ran`: **63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN,
none exited 3.** Five first refused on an unbuilt worktree and named
what they needed — `check:doc-formula-expressions`,
`check:doc-security-posture`, `check:docs-transcript-drift` (built
`@objectstack/lint`), spec `check:docs` (built spec / `gen:schema`),
spec `check:skill-examples` (built `@objectstack/client` and
`@objectstack/client-react`) — and all five are green after the two
builds, both run through `scripts/pm/os-verify-lock.sh` (VERDICT
`command-exit 0`, waited 0s).

That zero has controls on the reconciler itself: handed the PRE-BUILD
record the same tool reads "63 derived, 59 run, **4 NOT-MEASURED**";
handed a record with one family omitted it reads "**1 of 63 derived
families UNRUN**" and exits 1.

`pnpm lint` — the repo-wide `eslint . --no-inline-config`, CI's own
scope and not a narrowing — exits **0** at `b73fd40aa0`. The edited file
is a tool script, so its own suite is owed beyond the derived set: no
`*.test.ts` in the repository names `tenant-audit-census`, and the
module's only importer is `scripts/check-tenant-audit-census.mjs`, which
is in the derived set and green. The sibling
`scripts/platform-object-tenancy-census.mjs` carries no scope machinery
at all, so this shape does not exist there.

### The `this`-container repair — `this` is not "the enclosing class"

Commits `8f8828bf66` and `93555d0fcd`, answering the at-tier contract
review's blocking finding F1.

The object-literal repair put every member name under its container on
the RECORDING side. The `this.m()` LOOKUP still read "the chain of
enclosing classes": `calleeScopeChain`'s `this.` arm returned
`opensClassScope`, and both `thisProps.lookup` sites defaulted to the
same predicate. The language never resolves `this` that way. `this`
inside an object literal's own method is the LITERAL, whatever classes
enclose the literal; inside a `function` expression it is whatever the
call supplies; an arrow has none of its own. Measured, reading a
`this.getEngine()` written in an object-literal method through the
enclosing class SUBTRACTED a real engine write `origin/main` had PLACED
— under the DEFENDED `platform-type` arm, which prints nothing and is
counted nowhere, the direction this census's header names as the one it
cannot survive.

`thisContainerOf(site)` walks the parents: an arrow is TRANSPARENT; a
`function` expression, a `function` declaration and the source file
return `null`, which is the file-wide FLOOR alone; and a method,
accessor, constructor, property declaration or class static block STOPS
the walk and hands back ITS OWN parent — a class for a class member, the
OBJECT LITERAL for a literal's method. ⛔ A plain `key: value` property
assignment is not a stop, so `{ k: this.engine }` written in a class
method still reads the class. The `this.` arm and both
`thisProps.lookup` sites now pass a chain reaching EXACTLY that
container, and a miss inside it falls to the FLOOR — never outwards to a
class that merely encloses the site.

### The movement, measured on three trees

Receiver verdicts for the same synthetic sources, through the census's
own exported `declaredTypesIn` / `resolveReceiver` / `nonEngineReason`,
taken from three module versions in one run. `IProbeEngine` is in the
engine type index; the other type is a Map keyed by string
(`MAP_OF_STRING_TO_NUMBER`, spelled this way to keep the generic's angle
brackets out of a GitHub body). `tsc` is `--noEmit --strict
--ignoreConfig` on 6.0.3, the ORACLE: exit 0 means the compiler resolved
`insert` on the receiver.

| probe | shape | `origin/main` `544767da3b` | pre-fix head `b73fd40aa0`
| this head `93555d0fcd` | `tsc` |
|:--|:--|:--|:--|:--|:--|
| R1 | `this.getEngine()` in an object-literal method nested in a class
member; the literal's method declared first | **engine** |
other/platform-type | **engine** | 0 |
| R1r | the same, the class's method declared first |
other/platform-type | other/platform-type | **engine** | 0 |
| R2 | the literal's method is the file's ONLY `getEngine` (floor) |
engine | engine | engine | 0 |
| R8 | the same literal at FILE level, no enclosing class at all |
other/platform-type | other/platform-type | **engine** | 0 |
| R11 | an ARROW inside the literal's method | engine |
other/platform-type | **engine** | 0 |
| R13 | a GETTER on the literal | engine | other/platform-type |
**engine** | 0 |
| R6 | `this.engine` inside a `function` EXPRESSION in a class method |
engine | other/platform-type | **engine** (main's answer) | 2 `TS2683` |
| R7b | an inner anonymous CLASS EXPRESSION whose `this` lacks the name,
the floor holding the Map | other/platform-type | engine |
**other/platform-type** (main's answer) | 2 `TS2339` |
| R3 | `this.getEngine()` straight in a class method, beside a
same-named file-level function | engine | other/platform-type |
other/platform-type | 2 `TS2339` |
| R12 | a class declared INSIDE an object-literal method answers its own
`this.getEngine()` | engine | other/platform-type | other/platform-type
| 2 `TS2339` |
| R10 | `{ k: this.engine }` in a class method, two classes | engine,
engine | engine, platform-type | engine, platform-type | 0 |
| R9 | DARK: the literal's member under a DIFFERENT name |
other/platform-type | other/platform-type | other/platform-type | 2
`TS2339` |

R1 is F1 exactly as the review stated it, and R1r is the same shape in
the order where `origin/main` was wrong too. Controls on the same
instrument and run, all UNCHANGED between the pre-fix head and this one:
`this.PROP` in a class method and the same inside an ARROW read `engine,
platform-type` in both; R9 is the dark control (the fix is
name-specific); R3 and R12 are the class half this PR already landed,
and the repair leaves both standing, agreeing with the compiler.

**R7b's movement is acceptable, and it is the direction the repair must
take — not a regression.** Three readings, all on the same instrument
and run. ① The shape does not compile: `tsc` answers `TS2339: Property
'getEngine' does not exist`, because an inner class expression does not
inherit the lexically-enclosing class's members through `this`. A
tracked corpus that type-checks cannot contain it, and the corpus scan
below reads 0 either way. ② The pre-fix head's answer is the LANGUAGE
ERROR, not a capability being lost: walking outwards to the enclosing
class invents a resolution TypeScript does not have, and the repair
declines to invent it. ③ The new answer IS `origin/main`'s answer, so
this PR's own contract — 「The repair may not cost a single site that
resolved before」 — holds exactly. A regression here would be a site
moving from RESOLVED to unresolved relative to `origin/main`; this one
moves from an invented resolution back to main's. It is pinned in both
declaration orders rather than left to prose.

### The new pins can fail — eight ablations, each proven on disk

All through `scripts/ablation-replace.mjs`: anchor count plus blob hash
prove each mutation landed, blob-equals-HEAD plus an empty `git diff
HEAD` prove each restore, and `git diff HEAD --name-only` was asserted
empty after every leg. There is no build leg to reach — the module is
executed from source. Baseline `node
scripts/check-tenant-audit-census.mjs --self-test` exits 0 at **93 of
93** cases (81 + 12 new) plus the check's own 27.

| ablation | mutation | at `b73fd40aa0` | at this head |
|:--|:--|:--|:--|
| A0 | the callable lookup reverted to `lookup(fname, callee)` | 3 of 81
| **5 of 93** |
| A1 | the `this.` arm returns the floor | 1 of 81 | **5 of 93** |
| A2 | `opensClassScope` opens nothing | 6 of 81 | **8 of 93** |
| A3 | `opensCallableScope` opens nothing | 5 of 81 | **9 of 93** |
| A4 | the object literal removed from `opensCallableScope` | 2 of 81 |
**5 of 93** |
| A5 (new) | the `this.` arm reverted to `opensClassScope` — the pre-fix
spelling | — | **exactly 6 of 93** |
| A6 (new) | `thisProps.lookup` reverted to its default chain | — | **1
of 93** |
| A7 (new) | `thisContainerOf` stops at an arrow instead of passing
through it | — | **1 of 93** |

A5 is the direct ablation of this repair and every one of its six reds
carries the pre-fix `got` string. Each earlier count grew, and that is
composition rather than breakage: the new pins read the chain
`calleeScopeChain` hands back, so neutralising a predicate it depends on
now breaks them too. **This supersedes the `3 / 1 / 6 / 5 / 2 of 81`
reading above, which describes the tree at `b73fd40aa0`.** No ⛔
FLOOR-labelled pin reds under A2 or A3; two of them DO red under A0 and
A5, so the floor pins are not vacuously true either.

### The published population, re-measured — still zero, with three
controls

Three instruments over **one tree** via `runCensus({ root })` — ⚠️ a
tree whose HEAD was the fix commit `8f8828bf66`, ⛔ not the re-stamp
`93555d0fcd`, as the at-tier review measured from the JSON's own
`measuredAt.ref`: the `origin/main` `544767da3b` module (byte-identical
to `c120dbdfb8` for this file and `ts-parse.mjs`), the pre-fix
`b73fd40aa0` module, and this head's. All three produce **112739 bytes,
`sha256
4c35ebc1b26c876b48fa91e9e53d044ea4a159fd049624dca14ef5520abdb8cb`** at
that tree (the same bytes at `93555d0fcd` on 2026-09-23 hash
`71eb74aa2070af3c…`, differing only in `measuredAt.ref` — acceptance
note 6's hazard, this time in the `ref` field) — byte-identical — at
**227 write call sites, 153 decidable, 74 undecidable, 576 sources**, 63
engine types, 144 non-engine subtractions, `platform-type` 3, 0
unresolved, 0 unledgered. **0 of 227 change classification, in either
direction.**

- **FIRING** — add one tracked corpus file carrying the R1 shape with
`IDataEngine` and the comparison MOVES exactly as the review predicted:
`origin/main` reads **228 sites / 154 decidable / `platform-type` 3**
and PLACES the site with `placedBy: declared-type`; the pre-fix head
reads **227 / 153 / 145 subtractions / `platform-type` 4** and the site
is GONE; this head reads **228 / 154 / `platform-type` 3**,
byte-identical to `origin/main`. The `--json` names the file and the
line.
- **DARK** — the same added file with its `this` rooted on a CLASS
MEMBER instead of a literal's method is byte-identical on all three
instruments at 228 sites, so the movement is attributable to the shape
and not to the added file. Probe removed, `git status --porcelain` is
empty and the comparison returns to the byte-identical 227.
- **SCANNER** — over all 576 sources, the write call sites carrying a
`this`-rooted receiver number **184 by the round's own scanner** — ⚠️
not in the tree, and the at-tier review could not reproduce the 184
under four definitions (it reads **186**, or 182 counting raw text
starting `this.`), so treat the total as definition-dependent, and **0**
of them sit where the container their `this` names is not the nearest
enclosing class. That zero is the whole exposure of this repair. Firing
control with the probe tracked: **+1 site, 1 exposed**, naming the file;
dark control, the same file with a class-member `this`: **+1 site, 0
exposed** — the review reproduced both at 187 / 1 and 187 / 0. ⭐ The
load-bearing readings are the **0** and the **+1**, and both reproduce
under every definition.

### The prose the shape falsified

`opensClassScope`'s docblock said "the chain for a property is the chain
of enclosing classes"; it now says it is a RECORDING predicate and names
`thisContainerOf` as the lookup-side one, with the two shapes that break
the old claim. `calleeScopeChain`'s `this.m()` bullet no longer says
"the enclosing class". The existing pin named 「a `this.METHOD()` call
DOES read the enclosing class」 now reads 「…the class its `this` names」 —
the same case, the claim narrowed to what it actually holds. The
self-test success line states the pinned property for both containers,
through an arrow, inside a `function` expression, and for a plain `key:
value`.

### The two re-stamped artefacts

`node scripts/tenant-audit-census.mjs --write` at the instrument commit
rewrites **two lines in total** — the provenance stamp in each artefact,
`496d65dc7` → `8f8828bf6`. Every ENFORCED figure is byte-identical,
because the population did not move. `node
scripts/check-tenant-audit-census.mjs` exits **0**: "227 write call
sites certified (153 decidable; 9 tenancy-enabled sites PROVABLY carry
no tenant context, 34 more unreadable), 23 prose figures held to the
census". Neither artefact was hand-edited.

### Verification, re-derived at `93555d0fcd`

`node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands` from a worktree at this head derives **63 families** from
the same 3 changed paths (446 changed lines, under the 5000 human-merge
threshold). All 63 ran with each exit code captured BEFORE any pipe and
reconciled by `--ran`: **63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN,
none exited 3.** Five first refused on an unbuilt worktree and named
what they needed — `check:doc-formula-expressions`,
`check:doc-security-posture` and `check:docs-transcript-drift` (built
`@objectstack/lint`), spec `check:docs` and spec `check:skill-examples`
(built `@objectstack/spec`, then `@objectstack/client` and
`@objectstack/client-react`) — and all five are green after the two
builds, both run through `scripts/pm/os-verify-lock.sh` (`VERDICT
command-exit 0`; waited 64s and 229s).

That zero has controls on the reconciler itself: handed the PRE-BUILD
record the same tool reads "63 derived, 59 run, **4 NOT-MEASURED**";
handed a record with one family omitted it reads "**1 of 63 derived
famil(ies) UNRUN**" and exits 1.

`pnpm lint` — the repo-wide `eslint . --no-inline-config`, CI's own
scope and not a narrowing — exits **0** at `93555d0fcd`. The edited file
is a tool script, so its own suite is owed beyond the derived set: no
`*.test.ts` in the repository names `tenant-audit-census`, and `git grep
"from '.*tenant-audit-census.mjs'"` returns exactly one hit,
`scripts/check-tenant-audit-census.mjs`, which is in the derived set and
green.

## Acceptance notes

Eight things were found and deliberately NOT fixed here.

**1. A class's member types are not indexed, so `x.method()` still
conflates across two classes.** `declaredTypesIn`'s `shapes` map indexes
interfaces and type-literal aliases only, never `class` declarations.
Two classes each declaring a method `getEngine`, called from outside
both, answer `engine/IProbeEngine | engine/IProbeEngine` — a
`Map.delete` admitted. CONTROL: the identical member types declared as
INTERFACES are told apart correctly (`engine/IProbeEngine |
other/platform-type`), so the instrument can discriminate; what it
cannot do is read a class's members. CONTROL: a lone class member is
still correctly subtracted. Measured to PRE-EXIST this change and be
untouched by it: under the callable-scope ablation the same probe
answers identically. Different mechanism from this card's keying, so a
separate card rather than a rider.


**Re-measured on this head, and the pin's agreement with the compiler is
order-dependent.** With `A` declared first the census answers
`engine/IProbeEngine`, which is also what `tsc --noEmit --strict`
answers (exit 0). With the two classes in the other order the same
source answers `other/platform-type`, while `tsc` still answers the
engine (exit 0). So the file-wide FLOOR reaches the right verdict in the
pinned case by its first-typed-wins rule and not by reading `A`'s
members — which is exactly the card this note names. Measured to be
untouched by the callee-shape repair: the pre-fix head answers
`other/platform-type` in BOTH orders, so the repair moved the first
order and left the second where it was.

**2. `classifyHttp` reads an ABSENT rate-limit header as zero
remaining**, in `scripts/pm/label-write.mjs`. The guard is `(status ===
403 || status === 429) && Number(rateRemaining) === 0`, and
`Number(null)` is `0`, so a 403 carrying no `x-ratelimit-remaining`
header at all classifies as `ratelimit`. Through `stopSignalFrom` that
verdict writes a 30-minute STOP MARKER refusing EVERY write by that
token key, whatever path failed. Observed live in this container:
`scripts/pm/with-fleet.sh --read` took the egress proxy's 403 on the
App-token mint path and wrote `STOP MARKER written for token key
f665bb8f6447 until 2026-09-22T21:39Z`. CONTROLS: with the header present
at 4999 the same status classifies `refusal` and `stopSignalFrom`
returns null; with it genuinely 0 the intended `ratelimit` arm still
fires; a 404 near miss classifies `refusal`. The producer is honest —
`fleet-token.mjs`'s `rateRemainingOf` documents `null` as "absent" — and
the consumer misreads it.

**3. A known boundary, not a defect.** The file-wide FLOOR is kept on
all three maps, so a `this.PROP` no enclosing class declares, and a
method called from outside its class, still resolve file-wide. Removing
the floor would LOSE sites, which is the direction this census must
never fail in, so it is pinned in both new FLOOR cases rather than
closed.

**4. Acceptance note 2 was not re-measured this round.** The
`classifyHttp` reading above was taken in an earlier round of this
branch and is recorded here unchanged; this round neither reproduced nor
refuted it, and its own card carries it.

**5. Two self-test cases share one name verbatim.** `⭐⭐ …and in the
other declaration order, the real engine write is no longer subtracted
as a language global` names both a two-parameters case and a two-classes
case; ablation A2 reds both and prints the same line twice, so a reader
cannot tell which pin failed. Pre-existing — both predate this round.
This round hit it by writing a third collision of its own and removed
that one (`496d65dc79`); the pre-existing pair is left alone as out of
scope. Its own card or the next PR in this file, not a rider.

**6. A census `--json` hash names a DAY as well as a tree.**
`measuredAt()` returns `{ date: new Date().toISOString().slice(0,10),
ref }` and `runCensus` carries both, so a `sha256` over `--json` changes
at midnight UTC on a tree nobody touched. Measured: the `b73fd40aa0`
tree hashes `075734a25f19…` dated 2026-09-22 and `c18ad4dc836a…` dated
2026-09-23, identical apart from that one field. Not a defect in the
census — the date is deliberate (「tell a reader how old the unenforced
numbers are」) and the DRIFT gate already masks the whole unenforced
block out of its comparison — but a prose-citation hazard, and it has
now produced one wrong hash in this body. The ad-hoc three-instrument
byte comparison these rounds use is the thing that should mask
`measuredAt.date`, never the artefact. Carrier: the next PR to touch
`scripts/tenant-audit-census.mjs`. Noted, not filed.

**7. `thisContainerOf` treats every `function` expression as naming no
container, and one compiling shape disagrees.** TypeScript contextually
types `this` inside a `function` expression that is an object literal's
`key: value` as that literal. The at-tier review's probe X1 (`const o =
{ getEngine(): IProbeEngine …, z: function () {
this.getEngine().insert(…) } }` inside a class whose own `getEngine`
returns a Map) compiles (`tsc --noEmit --strict` exit 0) while this head
reads the floor and subtracts the write. ⛔ **Not a cost of this PR** —
`origin/main` and the pre-fix head answer identically, and the corpus
exposure is **0** (firing control 1 with the probe tracked). A
one-clause repair (a `FunctionExpression` whose parent is a
`PropertyAssignment` in an `ObjectLiteralExpression` names that literal)
plus a pin in both orders. Carrier: the next PR to touch
`scripts/tenant-audit-census.mjs`. Noted, not filed.

**8. The R7b ruling's third argument carries it; the first two
over-generalise.** An inner class expression that `extends` the outer
class inherits the member and **does** compile (probe R7x); there the
pre-fix head placed the write, and `origin/main` and this head both
subtract it. ⇒ 「walking outwards invents a resolution TypeScript does
not have」 is false for the inheriting case — the pre-fix answer was
right by the accident of lexical enclosure, and the census models no
inheritance at all (pre-existing, identical at `origin/main`). The same
family: a `function (this: C)` annotation. ⛔ Neither is a cost against
`origin/main` — ③, 「the new answer is `origin/main`'s」, is the
load-bearing leg and it stands. Exposure **0** in both (firing control 1
each). Carrier: the same next PR. Noted, not filed.

---

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr

---
_Generated by [Claude
Code](https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr)_

---
_Generated by [Claude Code](https://claude.ai/code)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
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

Development

Successfully merging this pull request may close these issues.

tenant-audit census collapses declared type text before re-parsing it (route ①), and conflates same-named locals across scopes

1 participant