fix(tooling): the tenant-audit census counts top-level object declarations only - #18209
Merged
Merged
Conversation
…tions only
`declaredObjects()` recursed into every object literal in a `*.object.ts(x)`
file and recorded every `name:` matching `/^[a-z][a-z0-9_]*$/`. That is not the
object-identity key alone: it also spells the grid column identity
(`inlineColumns`), the validation-rule id, the action name, the list-view name
and the index name. The registry read 300 "declared objects" out of 112 files
that declare 117.
The over-match was not confined to a printed figure. This name set is the
census's discriminator for `any`-typed receivers: runCensus()'s RESCUE promotes
an unresolved write call to `engine` -- to PLACED -- exactly when its first
argument names something in the set, and the same set answers each placed
site's tenancy posture. Over-matching widened the predicate that decides
whether a write call site is placed at all.
So the definition is now written down and enforced: a declared object is a
TOP-LEVEL declaration -- `export const X = ObjectSchema.create({ name: … })` --
and never a literal nested inside one, at any depth. A `*.object.ts(x)` file
that yields no top-level declaration REFUSES rather than contributing nothing,
because a shrunk set un-places live write call sites.
Measured on this tree: registry 300 -> 117, a strict subset (183 dropped, 0
added). The RESCUE places the same 18 sites before and after, and all 23
enforced population totals are unchanged; only the unenforced corpus-scale row
moves. Twelve self-test cases pin the rule in both directions, including the
two files the card measured: expense-report.object.ts still counts 2, and
invoice.object.ts's seven `inlineColumns` entries count 0.
Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk
Co-authored-by: Claude <noreply@anthropic.com>
This was referenced Sep 14, 2026
claude
Bot
deleted the
claude/issue-17663-declared-objects-top-level
branch
September 14, 2026 16:59
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #17663
Clause-②: no
The diff NARROWS the declared-object name set (300 -> 117, a strict subset: 183 dropped, 0 added) and therefore narrows, never widens, the
any-receiver RESCUE discriminator inrunCensus. No acceptance set grows and no public surface is added; the only published number that moves is the gate's own declared unenforced prose figure.The defect
scripts/tenant-audit-census.mjsdeclaredObjects()recursed into every object literal in a*.object.ts(x)file (ts.forEachChild(n, visit), unconditional) and recorded everyname:matching/^[a-z][a-z0-9_]*$/. That key is not the object identity alone: it also spells the grid column identity (inlineColumns), the validation-rule id, the action name, the list-view name and the index name.The damage was not confined to a printed figure. The same name set is the census's discriminator for
any-typed receivers — the RESCUE atrunCensuspromotes anunresolvedwrite call toengine, i.e. to PLACED, exactly when its first argument names something in the set, and the set also answers each placed site's tenancy posture. Over-matching widened the predicate that decides whether a write call site is placed at all.The definition, written down (acceptance 1)
A declared object is a top-level object declaration in a
*.object.ts(x)file: aconst/export constwhose initializer is an object literal, or a call whose first object-literal argument is one —ObjectSchema.create({ name: … }), the only spelling in this corpus — carrying aname:string literal. ⛔ A literal nested inside that declaration is never one, at any depth.It lives in the docblock over the new exported
topLevelObjectDeclarations(sf), together with why the rule is DEPTH rather than a callee name: keying onObjectSchema.createwould make the registry a function of one helper's identifier.⛔ A
*.object.ts(x)file that yields no top-level declaration now REFUSES, naming the file and the remedy. Silently contributing nothing is the direction that shrinks the RESCUE set, and a shrunk set un-places live write call sites rather than merely lowering a count.Readings — all taken in this worktree at
de2fb0f0d(based4554d4f5)The registry
*.object.ts(x)files walked0 added is the load-bearing half: the corrected set is a strict subset of the old one, which is what makes this a narrowing.
Controls (acceptance 2) — measured by running BOTH implementations over the real tree
examples/app-showcase/src/data/objects/expense-report.object.tsshowcase_expense_report,showcase_expense_lineexamples/app-showcase/src/data/objects/invoice.object.tsproduct,description,service_start,quantity,unit_price,receipt,amountshowcase_product,showcase_invoice,showcase_invoice_lineinlineColumnsentriespackages/platform-objects/src/identity/sys-api-key.object.tssys_api_keyplus 5 action / list-view names)sys_api_key)packages/plugins/plugin-security/src/objects/sys-position.object.tssys_position)The two tenancy opt-outs are still found and still opt out:
sys_api_key,sys_sso_provider.The RESCUE consequence (acceptance 3) — the diff is ZERO, and here is what the probe looked for
The
object-nameplacement branch places 18 sites before and 18 sites after, and they are the same 18 — compared as (file, line, receiver, verb, object name) tuples, not as a count. The diff, enumerated, is empty in both directions: 0 removed, 0 added.Three probes, so the zero is a reading rather than a silence:
placedBy === 'object-name'sites, before and after, byte-compared as tuples. Identical.writeCallSites223,tenancyEnabled,provablyNoTenantContext,placedByObjectName18,placedByObjectNameParameter15,placedByLedger11, the elevation triple, all of them. The 2 unledgered untyped receivers onmainare the same 2 sites, unchanged.⛔ So: no live write call site is placed differently by this change, and this PR asserts nothing about sites having been misplaced before it. The card's NOT MEASURED stays NOT MEASURED in that direction; what is now measured is that the widened predicate never met a live site on this tree at this commit.
The published figure (acceptance 4 and 5)
content/docs/permissions/tenant-audit-census.mdxstates 117 where it stated 300, and the generated corpus-scale block in both artefacts was regenerated bynode scripts/tenant-audit-census.mjs --write.tracked non-test sources scanned567 -> 568 andsame-named calls subtracted as non-engine138 -> 140 are pre-existing corpus drift since the block was last dated (2026-09-13), picked up by the regeneration. My own before/after measurement reads 568 and 140 on both sides of the fix. Onlydeclared objects in the registry300 -> 117 is mine.⛔ Per the gate's own contract that figure is unenforced, so the gate going green is not evidence this change is right — the readings above are. The two
UNENFORCED_PROSE_COUNTSself-test cases are untouched, and the gate self-test still reports the same 19 cases, unweakened; the census self-test grew from 18 to 30.Firing controls — every zero above has one
A. The 12 new self-test cases fail when the rule is reverted. Mutation:
topLevelObjectDeclarationsmade recursive again (the original defect), proven on disk by an occurrence count and a changed blob hash, restored by a trap and verified by a hash match against the HEAD blob plus an emptygit diff HEAD.B. The RESCUE counter moves — so the zero in acceptance 3 is a live reading. Mutation: one
any-receiver write call injected into a tracked corpus source (packages/services/service-settings/src/settings-service-plugin.ts:575), on-disk presence proven by occurrence count and blob hash, restored by trap and hash-verified.Leg 1 proves the old set really did place a write call site through a nested
name:, and that the new set refuses it into the census's declared error path rather than silently. Leg 2 proves the new discriminator still fires on a real name — the 18 is not a dead probe.Gates
node scripts/pm/dispatch-gates.mjs --commandsover the three changed paths derives 65 families. All 65 run, every exit code recorded:Five of them first answered PREREQUISITE NOT MET (exit 3, and one exit 1 of the same class) for unbuilt packages, not findings; they were re-run green after building
@objectstack/spec,@objectstack/formula,@objectstack/lint,@objectstack/client-react. Repo-widepnpm lintalso runs clean (exit 0), so no narrowing argument is owed.check:nul-bytesgreen, plus a hand scan of the three changed files for control bytes: no match.The census gate itself:
Scope note — one path beyond the dispatched file surface
The dispatched surface was
scripts/tenant-audit-census.mjsandcontent/docs/permissions/tenant-audit-census.mdx.docs/audits/2026-08-tenant-audit-write-call-sites.counts.mdis the second artefact the same--writeinvocation rewrites — the page's generated region cannot be updated any other way, and leaving one artefact saying 300 while the other says 117 would be incoherent. Declared here rather than done quietly.No changeset. Measured, not assumed: all three changed paths lie outside
packages/, and every one of the 70 published packages declares a package-relativefiles[], so nothing this diff touches can ship in any tarball. Per AGENTS.md that is theskip-changesetcase, not apatch— the label is applied. This is a declared deviation from the dispatch word, which asked for a changeset; an empty-frontmatter changeset is not an option either,scripts/check-empty-changeset.mjsrejects newly added ones.Acceptance notes
Noted, not filed — observations, no repro, no contract text:
main()exits 1 onorigin/maintoday (two unplaceableanyreceivers inpackages/plugins/organizations/), but no CI job invokes that file directly —lint.ymlruns onlycheck-tenant-audit-census.mjs, and that gate reads neitherunledgerednorstaleLedgerRows. So the module's declared "an unreadable receiver that none of the three place is an ERROR, never a default" has no CI teeth. Pre-existing, unchanged by this PR (2 before, 2 after), and named in the report for the triage seat to judge rather than fixed here: the repair lands in the gate file, which is outside this card's surface.declaredObjects()records only the FIRST file a name is seen in, so a per-file attribution built from that map under-reports a duplicate name's second home. It cost nothing here (no duplicates among the 117) and is noted only so the next reader of that map does not build a per-file census on it. Follow-up owner: none — no PR or person is queued on this file.Generated by Claude Code