Skip to content

fix(tooling): the tenant-audit census counts top-level object declarations only - #18209

Merged
claude[bot] merged 1 commit into
mainfrom
claude/issue-17663-declared-objects-top-level
Sep 14, 2026
Merged

claude[bot] merged 1 commit into
mainfrom
claude/issue-17663-declared-objects-top-level

Conversation

@claude

@claude claude Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

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 in runCensus. 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.mjs declaredObjects() recursed into every object literal in a *.object.ts(x) file (ts.forEachChild(n, visit), unconditional) and recorded every name: 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 at runCensus promotes an unresolved write call to engine, 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: a const / export const whose 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 a name: 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 on ObjectSchema.create would 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 (base d4554d4f5)

The registry

before after
declared objects 300 117
*.object.ts(x) files walked 112 112
names dropped / added 183 / 0

0 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

file old (recursive walk) new (top-level only)
examples/app-showcase/src/data/objects/expense-report.object.ts 2showcase_expense_report, showcase_expense_line 2 — same two
examples/app-showcase/src/data/objects/invoice.object.ts 10 — the 3 real ones plus product, description, service_start, quantity, unit_price, receipt, amount 3showcase_product, showcase_invoice, showcase_invoice_line
…of which inlineColumns entries 7 of 7 counted 0 of 7 counted
packages/platform-objects/src/identity/sys-api-key.object.ts 6 (sys_api_key plus 5 action / list-view names) 1 (sys_api_key)
packages/plugins/plugin-security/src/objects/sys-position.object.ts 9 1 (sys_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-name placement 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:

  1. Site-set comparison. All 18 placedBy === 'object-name' sites, before and after, byte-compared as tuples. Identical.
  2. Name-set intersection. The census reaches 36 distinct object names at statically-decidable sites. Intersected with the 183 dropped names: empty. Every one of the 36 is a genuine top-level declaration, so no site's placement or tenancy posture rode on an over-matched name.
  3. All 23 enforced population totals. Byte-identical before and after — writeCallSites 223, tenancyEnabled, provablyNoTenantContext, placedByObjectName 18, placedByObjectNameParameter 15, placedByLedger 11, the elevation triple, all of them. The 2 unledgered untyped receivers on main are 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.mdx states 117 where it stated 300, and the generated corpus-scale block in both artefacts was regenerated by node scripts/tenant-audit-census.mjs --write.

⚠️ Two of the four regenerated scale rows are NOT this change: tracked non-test sources scanned 567 -> 568 and same-named calls subtracted as non-engine 138 -> 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. Only declared objects in the registry 300 -> 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_COUNTS self-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: topLevelObjectDeclarations made 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 empty git diff HEAD.

SELF-TEST EXIT=1
  ✗ ⭐ `inlineColumns` entries are grid COLUMN identities, not declared objects -- got showcase_invoice_line,product,quantity,amount, want showcase_invoice_line
  ✗ validation-rule names are not declared objects -- got showcase_account,tax_id_format,discount_cap, want showcase_account
  ✗ action / list-view / index names are not declared objects -- got sys_user,invite_user,ban_user,all_users,idx_sys_user_org, want sys_user
  ✗ a declaration nested inside a function is NOT top-level -- got nested_object, want
  ✗ ⛔ a file with no top-level declaration yields NOTHING to declare -- got default_exported, want
  ✗ ⛔ a NESTED literal cannot opt anything out -- got sys_user,ban_user, want sys_user
✗ tenant-audit-census self-test: 6 of 30 case(s) failed.
  declaredObjects = 300      # the reverted rule reproduces the defect exactly

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 — the probe names `discount_cap` (a validationRules name, dropped by the fix)
  [OLD recursive walk] declaredObjects=300 placedByObjectName=19 unledgered=2 probe-site-placed=575:discount_cap
  [NEW top-level only] declaredObjects=117 placedByObjectName=18 unledgered=3
  [NEW top-level only] probe site landed in UNLEDGERED: packages/services/.../settings-service-plugin.ts:575

LEG 2 — the same probe naming `sys_secret` (a real top-level declaration)
  [OLD recursive walk] placedByObjectName=19  probe-site-placed=575:sys_secret
  [NEW top-level only] placedByObjectName=19  probe-site-placed=575:sys_secret

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 --commands over the three changed paths derives 65 families. All 65 run, every exit code recorded:

✓ dispatch-gates --ran: 65 derived famil(ies) accounted for — 65 run,
  0 NOT-MEASURED (a DERIVED zero — all 65 recorded an exit code and none of them is 3).

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-wide pnpm lint also runs clean (exit 0), so no narrowing argument is owed. check:nul-bytes green, plus a hand scan of the three changed files for control bytes: no match.

The census gate itself:

✓ check-tenant-audit-census: OK -- 223 write call sites certified (149 decidable;
  9 tenancy-enabled sites PROVABLY carry no tenant context, 32 more unreadable),
  23 prose figures held to the census.

Scope note — one path beyond the dispatched file surface

The dispatched surface was scripts/tenant-audit-census.mjs and content/docs/permissions/tenant-audit-census.mdx. docs/audits/2026-08-tenant-audit-write-call-sites.counts.md is the second artefact the same --write invocation 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-relative files[], so nothing this diff touches can ship in any tarball. Per AGENTS.md that is the skip-changeset case, not a patch — 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.mjs rejects newly added ones.

Acceptance notes

Noted, not filed — observations, no repro, no contract text:

  • The census module's own main() exits 1 on origin/main today (two unplaceable any receivers in packages/plugins/organizations/), but no CI job invokes that file directly — lint.yml runs only check-tenant-audit-census.mjs, and that gate reads neither unledgered nor staleLedgerRows. 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

…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>
@claude claude Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 14, 2026
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation labels Sep 14, 2026
@claude
claude Bot marked this pull request as ready for review September 14, 2026 16:26
@claude
claude Bot enabled auto-merge September 14, 2026 16:26
@claude
claude Bot added this pull request to the merge queue Sep 14, 2026
Merged via the queue into main with commit ea0b24a Sep 14, 2026
38 checks passed
@claude
claude Bot deleted the claude/issue-17663-declared-objects-top-level branch September 14, 2026 16:59
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

1 participant